diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 4c1a4f84..8aba4d11 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -49,6 +49,7 @@ cd frontend && npm install --legacy-peer-deps --no-audit --no-fund - Pre-push check: `git diff main..HEAD -- frontend/package.json frontend/package-lock.json` — если только один из двух тронут → STOP, regen lock. - Imports без deps entry (TypeScript авто-resolve через transitive) — **latent bomb** до first `npm ci`. - Reference incident: PR #344 (2026-05-17) добавил `lucide-react` без regen lockfile → deploy #135 fail → P0 hotfix PR #345 (commit `6ee20294f2`). +- **То же правило для `tradein-mvp/frontend/`** (#2770): там теперь тоже tracked `package-lock.json` + `npm ci` в Dockerfile и в `ci-tradein.yml`. До #2770 лока не было вовсе (лежал `pnpm-lock.yaml`, из которого никто не ставил), и состав зависимостей прод-образа определялся датой сборки. ## Prettier / lint diff --git a/.claude/rules/sql.md b/.claude/rules/sql.md index f221b4ef..136bc01a 100644 --- a/.claude/rules/sql.md +++ b/.claude/rules/sql.md @@ -16,11 +16,43 @@ paths: -- Контекст: что делает файл, зачем, порядок применения, dependencies. BEGIN; +SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout + -- DDL здесь (idempotent) COMMIT; ``` +## lock_timeout при блокирующем DDL (обязательно) + +Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) / +`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';` +сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml` +на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL). + +**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX` +на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической +psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за +ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только +ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный +отказ, повторить позже) вместо тихой очереди перед приложением. + +**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена +мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду, +поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит +очередь запросов приложения. + +**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует): +`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через +VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение, +оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально +в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и +обрежет `CONCURRENTLY` ниже по файлу. + +Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в +`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX +CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий. + ## Idempotency (обязательно) - `CREATE TABLE IF NOT EXISTS` diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 94b9d2ff..522cba14 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -30,6 +30,7 @@ jobs: outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} + browser: ${{ steps.filter.outputs.browser }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -54,21 +55,115 @@ jobs: frontend: - 'tradein-mvp/frontend/**' - '.forgejo/workflows/ci-tradein.yml' + browser: + # Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml + # members = backend + packages/*), со своим Dockerfile и без pyproject, + # поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе. + - 'tradein-mvp/browser/**' + - '.forgejo/workflows/ci-tradein.yml' backend-tests: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' + # Postgres-сервис (#2745). ДО него лэйн был mock-only: DATABASE_URL указывал на + # заведомо мёртвый `localhost:5432/test`, и девять тестов с `_live_session()` + # self-skip'ались — в CI они не бежали НИ РАЗУ. Так и разъехался со схемой + # test_house_dedup_merge (#2740: houses.url стал NOT NULL), а + # test_gar_flats_loader вообще падал до первого утверждения (#2744). + # + # Замер перед включением: полный сьют на mock-лэйне 122с / 3858 passed / 10 skipped, + # тот же сьют против живой БД — 106с / 3867 passed / 1 skipped. Живая БД не + # медленнее, поэтому НЕ добавляем второй job, а чиним этот: один прогон, на + # девять реальных проверок больше. Накладные — только подъём контейнера и + # bootstrap схемы (219 файлов, ~20с). defaults: run: working-directory: ./tradein-mvp/backend env: - # psycopg v3 требует parseable URL на импорте; реального коннекта нет — - # DB-тесты мокаются (mirror deploy-tradein.yml test-job). - DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test + # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. + CI_PG: ci-pg-tradein-${{ github.run_id }} steps: - uses: actions/checkout@v4 + - name: Поднять Postgres и собрать схему tradein + working-directory: . + # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА. + # Раннер запускает и job, и сервис-контейнеры с `--network host` (видно в + # логе прогона: `docker create image=... network="host"`), а на 5432 того + # же хоста слушает ПРОДОВЫЙ Postgres. Попытка через `services:` + + # `ports: 5432:5432` кончилась тем, что сервис-контейнер не смог занять + # порт, а psql из job'а ушёл В ПРОД и получил + # `password authentication failed for user "tradein"`. То есть + # `localhost:5432` из job'а на этом раннере — боевая база, а не тестовая. + # Поэтому контейнер поднимаем сами, в bridge-сети, БЕЗ публикации порта, + # и ходим по его собственному IP: прод недостижим в принципе, параллельные + # прогоны не конфликтуют, psql берём из самого контейнера. + # + # ОДИН шаг, а не два: между шагами контейнер успевал исчезнуть, и + # bootstrap падал на `container is not running`. + # + # `pg_isready -h 127.0.0.1`, а НЕ через unix-сокет: на время initdb образ + # поднимает ВРЕМЕННЫЙ сервер с listen_addresses='' — по сокету он уже + # отвечает «accepting connections», хотя снаружи БД ещё не существует, а + # впереди рестарт. Проба по TCP зеленеет только на настоящем сервере — + # том самом, к которому пойдут тесты. + # + # postgis, не plain postgres: tests/tasks/test_cadastral_geo_match.py + # проверяет KNN по geometry (PostGIS_Version() в connectivity-probe). + # Имя БД ОБЯЗАНО отличаться от `test`: `_live_session()` считает DSN с + # `localhost:5432/test` заглушкой и вернул бы None — контейнер поднялся + # бы, а тесты всё равно скипались. + run: | + set -u + docker rm -f "$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 + + ready="" + for _ in $(seq 1 45); do + if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U tradein -q 2>/dev/null; then + ready=1; break + fi + [ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break + sleep 2 + done + if [ -z "$ready" ]; then + echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)" + docker logs --tail 50 "$CI_PG" 2>&1 || true + exit 1 + fi + + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG") + [ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; } + echo "DATABASE_URL=postgresql+psycopg://tradein:tradein@${ip}:5432/tradein" >> "$GITHUB_ENV" + echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" + + # Тот же порядок и тот же строгий режим, что в deploy-tradein.yml: + # `ls | sort` + ON_ERROR_STOP=on, падение любой миграции → job RED. + # Никаких «применилось как получилось»: схема в CI либо та же, что на + # проде, либо гейта нет. + docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q -c \ + "CREATE EXTENSION IF NOT EXISTS postgis; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE ROLE gendesign_reader;" + for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do + fname=$(basename "$sql_file") + # ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а + # backfill, читающий foreign table gendesign_rosreestr_deals из БД + # ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING + # создать не из чего. На пустых таблицах backfill всё равно no-op. + if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then + echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет" + continue + fi + docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \ + || { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; } + done + echo "✓ схема собрана: $(docker exec "$CI_PG" psql -U tradein -d tradein -tAc \ + "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'") таблиц" + - name: Install uv # Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается # на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI). @@ -96,15 +191,87 @@ jobs: run: uv sync --frozen - name: Run pytest (tradein-mvp/backend) - # DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает - # ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state - # leak из другого test-модуля, pre-existing. Второй исторический deselect - # (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в - # полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан - # совпадать с test-job в deploy-tradein.yml. - run: | - uv run pytest -q \ - --deselect "tests/test_search_api.py::test_search_cache_hit" + # БЕЗ deselect'ов — сьют гоняется целиком (#2722). + # + # Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit` + # с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит — + # global-state leak из другого модуля». Объяснение было неверным в обеих + # половинах: тест падал и в изоляции тоже (401 vs 200), потому что он — + # единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка + # X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что + # фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке; + # заголовок добавлен, deselect снят, полный прогон зелёный. + # + # Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же + # класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо + # помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом. + # + # NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого + # deselect'а — он остаётся до #2680, который правит тот файл. Расхождение + # безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный. + # + # `-rs` (#2745) — КАЖДЫЙ пропуск печатает свою причину в лог job'а. Без него + # `-q` рисует пропуск точкой `s`, неотличимой на глаз от прогона: ровно так + # девять DB-тестов «шли зелёными», ничего не проверяя. Пропуск, который не + # называет себя вслух, со временем перестаёт быть верным. + run: uv run pytest -q -rs + + - name: Снести тестовый Postgres + # if: always() — контейнер уходит и когда сьют красный, и когда прогон + # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. + if: always() + working-directory: . + run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + + # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: + # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог + # вне uv-воркспейса, так что и `uv run pytest` из backend их не собирал. Итог: + # 4 теста лежали красными на main (с 2026-06-20 и 2026-07-02), файл при этом + # правился, и никто не узнал. Починка — PR #2724, этот job закрывает причину. + # + # Почему НЕ переиспользуем backend-job: + # 1. сайдкар не член воркспейса → `uv sync --frozen` его не ставит; + # 2. aiohttp (единственная не-stdlib зависимость сьюта) нет в tradein-mvp/uv.lock; + # 3. разный scope paths-filter: правка browser/ не должна гонять backend-сьют. + browser-tests: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.browser == 'true' + # Сьют идёт ~15с. Лимит — страховка от зависшего теста: у сайдкара нет своего + # pyproject, а значит и pytest-timeout'а backend'а (timeout=120). Дешевле + # взять нативный job-таймаут, чем тащить плагин ради одного каталога. + timeout-minutes: 10 + defaults: + run: + working-directory: ./tradein-mvp/browser + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + # 3.12 — как в browser/Dockerfile (FROM python:3.12-slim). + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install test deps + # ВЕСЬ список: pytest + aiohttp. Ни playwright, ни camoufox, ни закачки + # Firefox — camoufox импортируется ЛЕНИВО внутри _launch_browser + # (server.py, `from camoufox.async_api import AsyncCamoufox`), а сами тесты + # мокают _ensure_browser/_do_fetch и грузят server.py по пути через importlib. + # pytest-asyncio тоже НЕ нужен: ни одного `async def test_` — каждый тест сам + # крутит asyncio.run(). Проверено локально на venv ровно из этих двух пакетов. + # + # aiohttp без пина — ровно как в browser/Dockerfile (`pip install ... aiohttp`), + # то есть гейт видит ту же версию, что уедет в образ. Пин здесь означал бы + # проверку версии, которой в проде нет. + run: pip install pytest aiohttp + + - name: Run pytest (tradein-mvp/browser) + # Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего + # не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped. + # `-rs`: если однажды появится пропуск, он назовёт причину в логе, а не + # растворится в строке точек. + run: pytest -q -rs frontend-checks: runs-on: ubuntu-latest @@ -118,25 +285,42 @@ jobs: - name: Set up Node # Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine). - # npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет - # package-lock.json (Dockerfile ставит через npm install), а cache=npm - # без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint. + # cache: npm включён с #2770 — package-lock.json теперь tracked. uses: actions/setup-node@v4 with: node-version: "20" + cache: npm + cache-dependency-path: tradein-mvp/frontend/package-lock.json - - name: Install deps (npm install, no lockfile) - # ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage): - # --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches; - # --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`): - # в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но - # Dockerfile ставит именно npm install) → `npm ci` упал бы. - run: npm install --legacy-peer-deps --no-audit --no-fund + - name: Install deps (npm ci) + # ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage), чтобы гейт + # видел то же дерево, что уедет в образ. `ci`, а не `install` (#2770): до + # него лока не было вовсе (лежал мёртвый pnpm-lock.yaml, из которого никто + # не ставил), и версии в CI и в прод-образе выбирались независимо по дате + # сборки — гейт проверял не тот код, который деплоится. + # + # Правишь package.json — регенерируй лок в том же PR: `npm ci` требует + # точного match и иначе роняет и этот job, и build образа. + run: npm ci --legacy-peer-deps --no-audit --no-fund - name: Type-check (tsc --noEmit) # Blocking: любая TS-ошибка → job RED. run: npm run type-check + - name: Run tests (vitest) + # Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ + # проверки поведения: лэйн гейтил только типы и статический анализ, а оба + # молчат про то, что видит пользователь — пустое поле, погашенное число, + # отказ по частоте. Инфраструктура не изобретена, а взята у соседнего + # frontend/ (vitest + jsdom + testing-library), где сьют живёт давно. + # + # Пропусков в сьюте нет и быть не должно: сторож пропусков + # (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому + # пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722 + # запретил на бэкенде. Тест либо чинится, либо помечается `.fails` + # с причиной В КОДЕ. + run: npm test + - name: Lint (next lint) # Blocking: любая ESLint-ошибка → job RED. run: npm run lint diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index a261bc30..53e1d59b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -12,8 +12,18 @@ name: CI # единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через # connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango # установлен ниже), тогда как на macOS-dev они runtime-skip'аются. -# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см. -# .github/workflows/ci.yml как образец service-блока. +# +# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через +# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис- +# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ +# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод. +# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного +# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект +# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres +# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример +# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не +# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions) +# уронит сборку, если такая публикация всё же появится. on: # ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709). # WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым @@ -45,6 +55,25 @@ jobs: frontend: ${{ steps.filter.outputs.frontend }} steps: - uses: actions/checkout@v4 + + - name: "Guard: host-port collisions in workflows (#2757)" + # Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на + # КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает + # дефицитный слот раннера. Падение = merge заблокирован. + # python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3). + run: | + python3 scripts/check-workflow-ports.py --selftest + python3 scripts/check-workflow-ports.py + + - name: "Guard: блокирующий DDL без lock_timeout (#2752)" + # Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR, + # включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow — + # фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ + # каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину. + run: | + python3 scripts/check-migration-lock-timeout.py --selftest + python3 scripts/check-migration-lock-timeout.py + - uses: dorny/paths-filter@v3 id: filter with: @@ -69,6 +98,20 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' + # Postgres-сервис (#2745). Раньше DATABASE_URL указывал на заведомо мёртвый + # хост, и весь tests/sql/ (10 тестов: #17 velocity-alerts, #99 ДДУ-индикатор, + # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки + # не бежали ни разу с момента написания. + # + # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные + # таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему — + # проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не + # нужен, в отличие от tradein-лэйна. + # + # TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/ + # (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по + # SSH-туннелю. Пустой контейнер дал бы там красноту на пустом месте, поэтому + # integration остаётся честно пропущенным — с причиной в логе (`-rs`). defaults: run: working-directory: backend @@ -76,14 +119,53 @@ jobs: # TESTING=1 активирует RBAC-bypass (app/main.py rbac_guard пропускает # запросы при settings.testing=True) — иначе 401 на всём /api/v1. TESTING: "1" - # Stub DSN: psycopg v3 требует parseable URL на импорте; реального коннекта - # нет — DB-тесты мокаются, real-DB тест (tests/sql/) self-skip'ается через - # connectivity-probe к этому хосту (5432 недоступен → skip). - DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test REDIS_URL: redis://localhost:6379/0 + # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. + CI_PG: ci-pg-backend-${{ github.run_id }} steps: - uses: actions/checkout@v4 + - name: Поднять Postgres для тестов + working-directory: . + # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА — подробный разбор в + # ci-tradein.yml (тот же раннер). Кратко: job и сервис-контейнеры идут с + # `--network host`, а на 5432 этого хоста слушает ПРОДОВЫЙ Postgres, то + # есть `localhost:5432` из job'а — боевая база. Поднимаем контейнер сами, + # в bridge-сети, без публикации порта, ходим по его IP. + # + # `pg_isready -h 127.0.0.1`, а не через unix-сокет: по сокету отвечает + # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД + # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. + # + # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные + # таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы + # здесь не нужен вовсе, в отличие от tradein-лэйна. + run: | + set -u + docker rm -f "$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 + + ready="" + for _ in $(seq 1 45); do + if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U gendesign -q 2>/dev/null; then + ready=1; break + fi + [ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break + sleep 2 + done + if [ -z "$ready" ]; then + echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)" + docker logs --tail 50 "$CI_PG" 2>&1 || true + exit 1 + fi + + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG") + [ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; } + echo "DATABASE_URL=postgresql+psycopg://gendesign:gendesign@${ip}:5432/gendesign_ci" >> "$GITHUB_ENV" + echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" + - name: Set up Python uses: actions/setup-python@v5 with: @@ -136,10 +218,13 @@ jobs: # но --ignore — belt-and-suspenders на случай сбора фикстур). # tests/integration self-skip'ается через requires_test_db (skipif на # TEST_DATABASE_URL, который тут не задан) → НЕ игнорим, оно чисто skip'ается. - # tests/sql/ mv_layout self-skip'ается через Postgres-connectivity probe - # (5432 недоступен в этом mock-lane) → SKIP. Это intended. + # tests/sql/ теперь РЕАЛЬНО ИДУТ — postgres-контейнер выше (#2745). # PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK. # + # `-rs` (#2745): каждый оставшийся пропуск печатает причину. Под `-q` без + # него пропуск неотличим от прогона — именно так проверка тихо перестаёт + # исполняться и об этом узнают, когда на неё надо опереться (#2722/#2729/#2740). + # # Coverage-gate (#68): --cov=app меряет покрытие пакета app/. # --cov-fail-under=65 → job RED если покрытие упало ниже baseline # (измерено 2026-06: mock-lane сьют ~71%, см. [tool.coverage] в pyproject; @@ -148,7 +233,7 @@ jobs: # coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge). # term-missing → видно непокрытые строки прямо в job-логе. run: | - uv run pytest -q --ignore=tests/smoke \ + uv run pytest -q -rs --ignore=tests/smoke \ --cov=app \ --cov-report=term-missing:skip-covered \ --cov-report=xml:coverage.xml \ @@ -169,6 +254,13 @@ jobs: echo "$report" fi + - name: Снести тестовый Postgres + # if: always() — контейнер уходит и когда сьют красный, и когда прогон + # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. + if: always() + working-directory: . + run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + frontend-tests: runs-on: ubuntu-latest needs: changes diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index d2425ca4..d45dd831 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -28,12 +28,28 @@ jobs: frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }} browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }} infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }} - scraper: ${{ steps.set-all.outputs.scraper || steps.filter.outputs.scraper }} + # Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE + # в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend. + app_version: ${{ steps.build-meta.outputs.app_version }} + build_sha: ${{ steps.build-meta.outputs.build_sha }} + build_date: ${{ steps.build-meta.outputs.build_date }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + # Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник + # правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки — + # проброшены как build-args в build-backend/build-frontend ниже (см. + # tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile). + # Считается ОДИН раз здесь, а не в каждой job отдельно. + - name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE) + id: build-meta + run: | + echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT" + echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # Resolve base SHA: read last-successfully-deployed SHA from the VPS host file. # The file is written by the deploy job on every successful deploy. # Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD, @@ -91,7 +107,6 @@ jobs: echo "frontend=true" >> "$GITHUB_OUTPUT" echo "browser=true" >> "$GITHUB_OUTPUT" echo "infra=true" >> "$GITHUB_OUTPUT" - echo "scraper=true" >> "$GITHUB_OUTPUT" # Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges # (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes. @@ -107,28 +122,40 @@ jobs: # scheduler_main импортирует пакет) — kit-only изменение обязано # пересобрать образ, иначе деплой рестартует контейнеры на старом. - 'tradein-mvp/packages/scraper-kit/**' + # APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend + # ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ + # правок кода обязан пересобрать образ, иначе GET /version и колонтитул + # PDF продолжат отдавать старое значение при формально «успешном» деплое. + - 'tradein-mvp/VERSION' frontend: - 'tradein-mvp/frontend/**' + # NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же + # причина, что у backend выше. + - 'tradein-mvp/VERSION' + # /versions статически запекает CHANGELOG.md в билд (см. + # frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ + # frontend/** иначе не долетала бы до образа. + - 'tradein-mvp/CHANGELOG.md' browser: - 'tradein-mvp/browser/**' infra: - 'tradein-mvp/docker-compose.prod.yml' - 'tradein-mvp/deploy/**' - '.forgejo/workflows/deploy-tradein.yml' - scraper: - - 'tradein-mvp/backend/app/services/scrapers/**' - - 'tradein-mvp/backend/app/services/scrape_pipeline.py' - - 'tradein-mvp/backend/app/services/scheduler.py' - - 'tradein-mvp/backend/app/scheduler_main.py' - - 'tradein-mvp/backend/app/tasks/**' - # #2188: scheduler исполняет matching/dedup при каждом scrape-тике — - # без этих путей scraper-контейнер оставался на старом коде - # (2026-07-02: fias-dedup доехал до tradein-backend, но не до - # tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть - # scheduler — его правки тоже обязаны пересоздавать контейнер. - - 'tradein-mvp/backend/app/services/matching/**' - - 'tradein-mvp/backend/app/services/house_dedup_merge.py' - - 'tradein-mvp/packages/scraper-kit/**' + # УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом + # «файлов, которые исполняет планировщик», и перечислял только то, + # что вспомнили. Дважды выстрелило одинаково: + # 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не + # до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**, + # house_dedup_merge.py) — залатали случай, не механизм; + # 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py + # в списке не значились → планировщик час крутил старый код, + # деплой при этом отчитался успехом. + # За июнь-август 48% (193 из 402) backend-мержей не попадали ни в + # один из путей списка, т.е. половина правок доезжала до scraper'а + # только со следующим «удачным» деплоем. Теперь пересоздание + # привязано не к списку файлов, а к факту пересборки образа — + # см. SCRAPER_RECREATE в job deploy. # Quality gate: pytest MUST pass before any image is built/deployed (#666). # Runs the tradein-mvp/backend suite; a red test blocks build + deploy. @@ -164,15 +191,25 @@ jobs: run: uv sync --frozen - name: Run pytest (tradein-mvp/backend) - # DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает - # ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state - # leak из другого test-модуля, pre-existing. Второй исторический deselect - # (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном - # прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан - # совпадать с backend-tests в ci-tradein.yml (pre-merge гейт). - run: | - uv run pytest -q \ - --deselect "tests/test_search_api.py::test_search_cache_hit" + # БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml. + # + # Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с + # объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит — + # global-state leak из другого модуля». Объяснение было неверным в обеих + # половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в + # /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на + # такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в + # pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё + # на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт. + # + # Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс + # дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо + # помечается xfail с причиной В КОДЕ, рядом с самим тестом. + # + # `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне — + # 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый + # пропуск роняет прогон через хук в tests/conftest.py. + run: uv run pytest -q -rs build-backend: runs-on: ubuntu-latest @@ -201,6 +238,13 @@ jobs: context: ./tradein-mvp file: ./tradein-mvp/backend/Dockerfile push: true + # APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см. + # backend/Dockerfile ARG→ENV) — читает app/core/version.py: + # GET /api/v1/trade-in/version + колонтитул PDF-отчёта. + build-args: | + APP_VERSION=${{ needs.changes.outputs.app_version }} + BUILD_SHA=${{ needs.changes.outputs.build_sha }} + BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | @@ -226,6 +270,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context + # (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста, + # поэтому копируем внутрь ДО build. /versions статически запекает его + # содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage + # комментарий). Не влияет на кэш другого шага — читается только этим. + - name: Stage CHANGELOG.md into frontend build context + run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md + - name: Build & push tradein-frontend uses: docker/build-push-action@v6 with: @@ -236,9 +288,15 @@ jobs: # (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в # dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница # уходит в notFound (404), не индексируется и не краулится. + # NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js + # инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит, + # см. frontend/Dockerfile комментарий у соответствующих ARG). build-args: | NEXT_PUBLIC_BASE_PATH=/trade-in NEXT_PUBLIC_API_BASE_URL=/trade-in + NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} + NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} + NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | @@ -294,21 +352,32 @@ jobs: uses: appleboy/ssh-action@v1.0.3 env: IMAGE_TAG: latest + # Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не + # уходить в drain, когда пересоздавать нечего (см. ниже, #2679). + IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }} GHCR_PAT: ${{ secrets.GHCR_PAT }} - # Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT - # recreate the scraper and SIGKILL a running multi-hour job. Only genuine - # scraper-code paths (the `scraper` paths-filter already covers - # app/services/scrapers/**, scrape_pipeline.py, scheduler.py, - # scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch — - # should trigger a scraper recreate. (infra term intentionally dropped.) - SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }} + # #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ + # gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса, + # одна строка image, разный command). Значит вопрос «пересоздавать ли + # scraper» — это не «трогали ли его файлы», а «мог ли пересобраться + # образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend: + # backend || infra || workflow_dispatch. Ровно тогда в реестре мог + # появиться новый :latest, и оставить scraper на старом — значит + # оставить планировщик на старом коде (инцидент #2679). + # + # Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ + # пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс + # больше не нужен — с #1951 перед recreate'ом идёт graceful drain + # (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам + # `compose up -d` на неизменившемся образе — no-op. + SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }} GITHUB_SHA: ${{ github.sha }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED,GITHUB_SHA + envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA script: | set -euo pipefail cd /opt/gendesign @@ -431,6 +500,35 @@ jobs: done echo "All migrations applied." + # (3b) Невалидные индексы после цикла (#2752). Оборванный + # CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false: + # планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка + # на записи всё равно платится. Молчит это так (воспроизведено на + # PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена + # применённой → следующий деплой прогоняет её заново → `CREATE INDEX + # CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает + # «relation already exists, skipping», выходит с кодом 0 → миграция + # помечается применённой, а индекс остаётся невалидным навсегда. + # Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она + # ловит и этот путь, и невалидные индексы любого другого + # происхождения (отменённый job, ручной CIC оператором). + # На 2026-08-07 на проде таких индексов 0 — это профилактика. + invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ + psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ + "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ + | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ + || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } + if [ -n "$invalid_idx" ]; then + echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" + echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" + echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" + echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." + echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" + echo " пересоздать индекс и повторить деплой." + exit 1 + fi + echo "✓ невалидных индексов нет." + # Bootstrap gendesign_reader password from env (post-migration, #976). # SQL migration 101_gendesign_reader_role.sql creates role passwordless; # password lives only in /opt/gendesign/tradein-mvp/.env.runtime. @@ -489,8 +587,43 @@ jobs: # с browser/backend/frontend, отдельного graceful-drain не требует. SERVICES="browser backend frontend tgbot" SCRAPER_STOP_TS="" - if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then - echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)" + scraper_stale="" + if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then + # Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE + # истинно и на infra-правках (compose / workflow / deploy/**), а те + # почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется, + # `up -d` выходит no-op — и платить за него пятиминутным drain'ом, + # прерывая многочасовой сбор, не за что. Сравниваем, на том ли + # образе бежит scraper, что уже лежит в локальном демоне. + # ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) — + # до pull'а под тегом :latest ещё старый образ, сравнение всегда + # «совпало» и drain пропускался бы как раз тогда, когда он нужен. + # Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на + # ЭТОТ же признак и больше не выполняются, когда recreate'а не было + # (иначе живой прогон с heartbeat старше чекпоинта помечался бы + # 'cancelled', продолжая работать). + pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "") + running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "") + if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then + echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего," + echo " drain пропускаем, in-flight прогоны не трогаем" + else + scraper_stale="yes" + fi + # scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d` + # — no-op, но правка самого compose (env/лимиты сервиса) так всё же + # доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а — + # страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку + # прогона подчистит периодический 6h zombie-reaper. + SERVICES="$SERVICES scraper" + else + echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем" + echo " (сверка образов ниже всё равно проверит, что он не отстал)" + fi + + if [ -n "$scraper_stale" ]; then + echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);" + echo " ждём слива in-flight scrape_runs (до 5 мин)" drained="" for i in $(seq 1 30); do # NB: не сливать "psql не ответил" с "0 running" — иначе неудачный @@ -527,15 +660,11 @@ jobs: SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS="" echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}" - - SERVICES="$SERVICES scraper" - else - echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)" fi docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES - if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then + if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" # NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до # сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD) @@ -653,7 +782,7 @@ jobs: # снижает шанс поймать контейнер ровно в момент between-restarts # промежуточного "running" внутри crash-loop. # tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES); - # scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому + # scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому # проверяем только то, что реально входит в текущий $SERVICES. for svc in tgbot scraper; do case " $SERVICES " in @@ -683,6 +812,49 @@ jobs: echo "→ tradein-$svc running." done + # Сверка образов backend-семейства (#2679) — последняя проверка перед + # маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ + # gendesign-tradein-backend; backend пересоздаётся на каждом деплое + # (безусловно в $SERVICES) и потому всегда несёт свежий :latest — + # он и есть эталон. Если у scraper или tgbot image ID другой, значит + # контейнер остался на старом коде, а деплой без этой проверки + # отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до + # tradein-backend, ff98603ba3cc; tradein-scraper остался на + # da26154c64a6 часовой давности — а планировщик, единственный + # исполнитель домовой оценки, живёт именно там). + # Падаем, а не warning'уем: расхождение = правка не работает, и + # узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт + # ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же + # базу и пересоберёт всё накопленное (тот же приём, что в health-check). + # «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся + # по-разному, поэтому сообщения различаются явно. + backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "") + image_mismatch="" + if [ -z "$backend_image" ]; then + echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем." + image_mismatch="yes" + fi + for svc in scraper tgbot; do + svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "") + if [ -z "$svc_image" ]; then + echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший" + echo " образ, а неполный стек: сервис не работает вообще." + image_mismatch="yes" + elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then + echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image" + image_mismatch="yes" + fi + done + if [ -n "$image_mismatch" ]; then + echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)." + echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):" + echo " docker compose -p gendesign-tradein \\" + echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\" + echo " up -d --force-recreate --no-deps backend scraper tgbot" + exit 1 + fi + echo "→ образы совпадают: backend/scraper/tgbot на $backend_image." + # Cleanup старых образов for repo in ghcr.io/lekss361/gendesign-tradein-backend \ ghcr.io/lekss361/gendesign-tradein-frontend; do diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 7303d4cf..f267444a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -309,6 +309,31 @@ jobs: done echo "All migrations applied." + # Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX + # CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ + # пользуется, а re-run миграции его не чинит — `CREATE INDEX + # CONCURRENTLY IF NOT EXISTS` печатает «relation already exists, + # skipping» и выходит с кодом 0, после чего миграция помечается + # применённой, а индекс остаётся битым навсегда (воспроизведено на + # PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY. + # Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07 + # на проде таких индексов 0 — это профилактика. + invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ + | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ + || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } + if [ -n "$invalid_idx" ]; then + echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" + echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" + echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" + echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." + echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" + echo " пересоздать индекс и повторить деплой." + exit 1 + fi + echo "✓ невалидных индексов нет." + # Set tradein_fdw_reader password from env (post-migration bootstrap). # SQL migration 100_tradein_fdw_role.sql creates role passwordless; # password lives only in /opt/gendesign/backend/.env.runtime. diff --git a/backend/.env.example b/backend/.env.example index d17d9dd7..192a52d7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800 SCRAPE_KN_DEFAULT_REGIONS=66 # Путь к Playwright storage_state.json (commited в git, обновляется --save-state). SCRAPE_KN_STATE_PATH=data/playwright_state.json -# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен). -# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep. -# Переменная сохранена в core/deps.py для быстрого rollback. -SCRAPE_ADMIN_TOKEN= +# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437, +# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось +# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard +# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426). diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 74bfb5c9..191ed5c4 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2189,12 +2189,21 @@ def analyze_parcel( _effective_weights = {**_POI_WEIGHTS, **_inline_weights} _weights_source = "inline" else: - _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) - _weights_source = ( - "profile" - if profile_id is not None - else ("user_default" if profile_user_id is not None else "system") - ) + # Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811): + # profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и + # тогда веса системные или дефолтные, а не профильные. + _resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) + _effective_weights = _resolved.weights + _weights_source = _resolved.source + + # «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит, + # был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" — + # иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли». + # None когда profile_id не передавали; False когда передали, но применилось + # другое (не найден / чужой / перебит inline-весами). + _requested_profile_applied: bool | None = ( + None if profile_id is None else _weights_source == "profile" + ) # 4) Scoring: weighted sum с distance decay score = 0.0 @@ -4085,9 +4094,12 @@ def analyze_parcel( # (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри. "program_alternatives": program_alternatives, # #114/#201: кастомные веса POI — source + applied dict для прозрачности. + # source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли + # удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали. "weights_profile": { "source": _weights_source, "profile_id": profile_id, + "requested_profile_applied": _requested_profile_applied, "user_id": profile_user_id, "weights_applied": _effective_weights, "inline_weights": _inline_weights, @@ -4203,6 +4215,7 @@ def analyze_parcel( "profile_user_id": profile_user_id, "inline_weights": _inline_weights, "weights_source": _weights_source, + "requested_profile_applied": _requested_profile_applied, "x_session_id": _session_id, }, district=_district_name, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9a84b522..0b9ce491 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -146,9 +146,6 @@ class Settings(BaseSettings): # Path to a pre-captured Playwright storage_state.json (committed in repo, # used by worker to skip cold-start WAF challenge). scrape_kn_state_path: str = "data/playwright_state.json" - # Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls. - # Empty string = endpoint disabled. - scrape_admin_token: str = "" # ── #1945 KN-loader anti-ban (throttle + optional proxy) ────────────────── # DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py deleted file mode 100644 index 75fd3b06..00000000 --- a/backend/app/core/deps.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Shared FastAPI dependencies.""" - -from typing import Annotated - -from fastapi import Depends, Header, HTTPException, status - -from app.core.config import settings - - -def verify_admin_token( - x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, -) -> None: - """Verify admin token header. Raises 503 if not configured, 401 if invalid or missing.""" - if not settings.scrape_admin_token: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="admin disabled — set SCRAPE_ADMIN_TOKEN", - ) - if x_admin_token != settings.scrape_admin_token: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token") - - -AdminTokenAuth = Annotated[None, Depends(verify_admin_token)] diff --git a/backend/app/main.py b/backend/app/main.py index ee3969ec..e0ac46cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,7 +48,7 @@ from app.core import auth_db from app.core.audit_middleware import audit_log_middleware from app.core.auth import get_role from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event from app.services.auth_session import resolve_session_token logger = logging.getLogger(__name__) @@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers): # (middleware, маршруты) видели активный client с самого старта процесса. # GlitchTip не поддерживает profiling — profiles_sample_rate=0.0. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review): + # Starlette-интеграция кладёт request.data на transaction-scope так же, как + # на error-scope, поэтому голый scrub_sensitive_query (только URL) на + # before_send_transaction оставлял бы PII-канал открытым при любом + # glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring). sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -82,8 +87,14 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send=scrub_sensitive_query, - before_send_transaction=scrub_sensitive_query, + # Локальные переменные кадров стека НЕ уходят в мониторинг (#2753). + # Дефолт SDK — True: при любом исключении кадр несёт значения аргументов + # (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event + # сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не + # дополнительная мера, а условие, без которого скраб не полон. + include_local_variables=False, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ StarletteIntegration(), FastApiIntegration(), diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py index af936c31..66f9c2cc 100644 --- a/backend/app/observability/sentry_scrub.py +++ b/backend/app/observability/sentry_scrub.py @@ -1,17 +1,72 @@ -"""Хук before_send_transaction для GlitchTip/Sentry SDK. +"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK. -Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы -секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip -через HttpxIntegration performance-spans. +`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед +отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в +GlitchTip через HttpxIntegration performance-spans. + +`scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone / +client_email / phone / email / name / company / message) из events перед +отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на +sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в +request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в +`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/ +`message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный +вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет +сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/ +sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/ +client_email — Птица их не использует сегодня, но одинаковый механизм на +оба продукта проще сопровождать), плюс `company`/`message`, специфичные для +`PilotRequestInput` (#2457-review). + +`scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым +надо вешать ОБА канала — `before_send` И `before_send_transaction`. +Starlette-интеграция кладёт тело запроса в `request_info["data"]` на +transaction-scope точно так же, как на error-scope (scope-обработчики для +transactions НЕ пропускаются — пропуск бывает только на availability-чеках). +Если повесить PII-scrub только на `before_send`, а `before_send_transaction` +оставить на голом `scrub_sensitive_query` — PII продолжит течь через +transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review, +воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в +транзакцию с полным телом). """ from __future__ import annotations +import logging import re from typing import Any +from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.types import Event +logger = logging.getLogger(__name__) + +# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753). +# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе +# в новое событие, которое снова пойдёт через этот же обработчик; при +# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено: +# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика +# остаётся в stdout контейнера: текст трассировки значений переменных не несёт. +ignore_logger(__name__) + +_REDACTED = "[REDACTED]" +# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ +# (client_name/client_phone/client_email/phone/email/name, #396) + company/ +# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля +# свободного текста (#2457-review). +_PII_KEYS = frozenset( + { + "client_name", + "client_phone", + "client_email", + "phone", + "email", + "name", + "company", + "message", + } +) + _SENSITIVE_PARAM_RE = re.compile( r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)", re.IGNORECASE, @@ -47,3 +102,63 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None: request["url"] = _redact(request["url"]) return event + + +def _scrub(obj: Any) -> None: + """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" + if isinstance(obj, dict): + for key, value in obj.items(): + if isinstance(key, str) and key.lower() in _PII_KEYS: + obj[key] = _REDACTED + else: + _scrub(value) + elif isinstance(obj, list): + for item in obj: + _scrub(item) + + +def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: + """Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction) + перед отправкой в GlitchTip. + + Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list), + заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event + (не None) — иначе SDK дропнет отчёт целиком. + """ + if not isinstance(event, dict): + return event + request = event.get("request") + if isinstance(request, dict): + _scrub(request.get("data")) + _scrub(event.get("extra")) + _scrub(event.get("contexts")) + return event + + +def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None: + """Composed `before_send` / `before_send_transaction` handler: PII-scrub + + URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module + docstring (#2457-review): transaction-scope несёт `request.data` точно так + же, как error-scope. + + try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в + `capture_internal_exceptions`, который при исключении внутри хендлера + ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что + редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты + покрытия редактора: лучше отправить событие в состоянии "сколько успели + отредактировать до сбоя", чем не отправить вообще и молча остаться без + сигнала в мониторинге. + """ + try: + scrub_pii_event(event, hint) + scrub_sensitive_query(event, hint) + except Exception as exc: + # Ни трассировки, ни str(exc): и то и другое способно нести значения из + # ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что + # защищает (#2753). Имя класса исключения данных не несёт. Событием + # мониторинга эта строка не станет — см. ignore_logger выше. + logger.error( + "sentry_scrub.scrub_event: handler failed (%s), sending event as-is", + type(exc).__name__, + ) + return event diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index d85d24ca..6d6f139e 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index c71725af..6087e4a4 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str: ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 08a253d1..7639c02d 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -10,7 +10,7 @@ API surface: - create_profile(db, payload) → WeightProfile - update_profile(db, user_id, profile_id, payload) → WeightProfile | None - delete_profile(db, user_id, profile_id) → bool -- resolve_weights(db, user_id, profile_id) → dict[str, float] +- resolve_weights(db, user_id, profile_id) → ResolvedWeights(weights, source) """ from __future__ import annotations @@ -19,7 +19,7 @@ import json import logging import math from datetime import datetime -from typing import Any +from typing import Any, NamedTuple from pydantic import BaseModel, Field, field_validator from sqlalchemy import text @@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool: return True -def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]: - """Вернуть эффективные веса для analyze_parcel. +class ResolvedWeights(NamedTuple): + """Веса + КАКОЙ источник фактически применился (#2811). + + Лестница приоритетов ниже по построению стирает разницу между «взял, что + просили» и «не нашёл, взял что было» — а метка в ответе /analyze строится + именно на этой разнице. Поэтому источник возвращается вместе с весами, а не + выводится вызывающим из своих же входных параметров. NamedTuple, а не голый + dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко, + молча «весами» этот объект не притворится. + """ + + weights: dict[str, float] + source: str # "profile" | "user_default" | "system" + + +def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights: + """Вернуть эффективные веса для analyze_parcel + фактический их источник. Порядок приоритетов: - 1. profile_id задан → загрузить именно этот профиль - 2. user_id задан → загрузить default-профиль пользователя - 3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS + 1. profile_id задан → загрузить именно этот профиль → source="profile" + 2. user_id задан → загрузить default-профиль пользователя → source="user_default" + 3. Иначе → системные значения _SYSTEM_POI_WEIGHTS → source="system" + + Запрошенный, но НЕ применённый profile_id — не тишина: warning с + идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для + /analyze — необязательный модификатор, а не адресуемый ресурс; 404 превратил + бы гонку «профиль удалили между списком и анализом» в отказ вместо честно + помеченного ответа. Клиенту хватает source + requested_profile_not_found. """ if profile_id is not None and user_id is not None: profile = get_profile(db, user_id, profile_id) @@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic logger.debug( "resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id ) - return dict(profile.weights) + return ResolvedWeights(dict(profile.weights), "profile") + resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system") if user_id is not None: profile = get_default_profile(db, user_id) if profile is not None and profile.weights: - logger.debug("resolve_weights: user=%s → default profile weights", user_id) - return dict(profile.weights) + resolved = ResolvedWeights(dict(profile.weights), "user_default") - logger.debug("resolve_weights: returning system defaults") - return dict(_SYSTEM_POI_WEIGHTS) + if profile_id is not None: + # Сюда попадаем, если запрошенный профиль не применился: owner не передан + # (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights + # пустые. Раньше это был logger.debug, которого на проде нет, — и оценка + # молча считалась не по тем весам (#2811, ранее #2788). + logger.warning( + "resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — " + "фактический источник весов %r", + profile_id, + user_id, + resolved.source, + ) + else: + logger.debug("resolve_weights: источник весов %s", resolved.source) + return resolved diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 1268bfe6..bfba175d 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event logger = logging.getLogger(__name__) @@ -23,6 +23,11 @@ logger = logging.getLogger(__name__) # чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного # вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review, + # см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker + # вообще не скрабил error-события (тут before_send не было), а + # before_send_transaction был на голом scrub_sensitive_query (только URL) — + # оба канала пропускали PII. sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -30,7 +35,12 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send_transaction=scrub_sensitive_query, + # Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см. + # app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно. + # В воркере вектор шире: задачи держат в кадрах сырые ответы источников. + include_local_variables=False, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ CeleryIntegration(monitor_beat_tasks=True), SqlalchemyIntegration(), diff --git a/backend/tests/_sentry_wiring_probe.py b/backend/tests/_sentry_wiring_probe.py new file mode 100644 index 00000000..a2c72bf4 --- /dev/null +++ b/backend/tests/_sentry_wiring_probe.py @@ -0,0 +1,146 @@ +"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py. + +Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут +`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В +процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а +если бы и был достижим — глобальный клиент SDK остался бы живым для всех +последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает +вместе с ней. + +Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а +DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются +случайно на каждый запуск — кадр стека несёт не только переменные, но и строки +исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной. + +stdout — одна строка JSON: counts / scrub_handler_entries / markers / payloads +(тело каждого канала отдельно — см. `main`). +""" + +from __future__ import annotations + +import importlib +import io +import itertools +import json +import sys +import uuid +from typing import Any + +import sentry_sdk + +_FAILURE_CAP = 3 + + +def _fresh(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex}" + + +def _leaking_event(markers: dict[str, str]) -> dict[str, Any]: + """Событие с ПДн в трёх местах, которые закрывает scrub_event.""" + return { + "message": "sentry-wiring-probe", + "level": "error", + "request": { + "data": {"phone": markers["phone"], "message": markers["free_text"]}, + "url": f"https://example.invalid/probe?api_key={markers['url_secret']}", + }, + } + + +def main(module: str) -> int: + importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init() + + client = sentry_sdk.get_client() + sent: list[str] = [] + + def _record(envelope: Any) -> None: + buf = io.BytesIO() + envelope.serialize_into(buf) + sent.append(buf.getvalue().decode("utf-8", "replace")) + + client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign] + + markers = { + "phone": _fresh("probe-phone"), + "free_text": _fresh("probe-free-text"), + "url_secret": _fresh("probe-url-secret"), + "local_var": _fresh("probe-local-var"), + } + + # 1. Канал ошибок (before_send). + sentry_sdk.capture_event(_leaking_event(markers)) + after_error = len(sent) + + # 2. Канал транзакций (before_send_transaction) — Starlette кладёт + # request.data на transaction-scope так же, как на error-scope. + transaction = _leaking_event(markers) + transaction["type"] = "transaction" + transaction["transaction"] = "sentry-wiring-probe-tx" + transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}} + transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z" + transaction["timestamp"] = "2026-01-01T00:00:01.000000Z" + transaction["spans"] = [] + sentry_sdk.capture_event(transaction) + after_transaction = len(sent) + + # 3. Локальные переменные кадра стека (include_local_variables). Имя + # переменной произвольное — ключевой скраб такое не ловит по построению. + def _raise_with_local() -> None: + applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека + raise RuntimeError("sentry-wiring-probe boom") + + try: + _raise_with_local() + except RuntimeError: + sentry_sdk.capture_exception() + after_exception = len(sent) + + # 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка + # журнала об отказе уходит в мониторинг через LoggingIntegration, снова + # попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса + # проверено: не завершается, 1000+ вложенных трассировок за минуту). + # Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе + # проба на сломанном коде висела бы вместо того, чтобы честно покраснеть. + from app.observability import sentry_scrub + + original = sentry_scrub.scrub_pii_event + entries: list[int] = [] + + def _boom(*_a: Any, **_kw: Any) -> Any: + entries.append(1) + if len(entries) > _FAILURE_CAP: + return None + raise RuntimeError("sentry-wiring-probe scrubber failure") + + sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment] + try: + # Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is"). + sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"}) + finally: + sentry_scrub.scrub_pii_event = original # type: ignore[assignment] + after_scrub_failure = len(sent) + + # Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные + # переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит + # чужую проверку и мешает понять, что именно сломано. + bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure] + names = ["error", "transaction", "exception", "scrub_failure"] + spans = dict(zip(names, itertools.pairwise(bounds), strict=True)) + + print( + json.dumps( + { + "counts": {name: end - start for name, (start, end) in spans.items()}, + "scrub_handler_entries": len(entries), + "markers": markers, + "payloads": { + name: "\n".join(sent[start:end]) for name, (start, end) in spans.items() + }, + } + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/backend/tests/api/v1/test_admin_cadastre.py b/backend/tests/api/v1/test_admin_cadastre.py index 3df39895..9d7fdb27 100644 --- a/backend/tests/api/v1/test_admin_cadastre.py +++ b/backend/tests/api/v1/test_admin_cadastre.py @@ -5,36 +5,16 @@ from __future__ import annotations -# scrape_cadastre зависит от app.scrapers.nspd_bulk_client (PR 2/5). -# Используем importlib.util.find_spec вместо прямого import — иначе -# `import app.workers...` пересоздаёт `app` как Python package и -# перебивает FastAPI instance, привязанный в строке выше → AttributeError -# на app.dependency_overrides. -import importlib.util +# NB: `import app.workers...` здесь запрещён — он пересоздаёт `app` как Python +# package и перебивает FastAPI instance, привязанный ниже → AttributeError на +# app.dependency_overrides. Патчим воркер строкой, через patch("app.workers..."). from typing import Any from unittest.mock import MagicMock, patch -import pytest from fastapi.testclient import TestClient from app.main import app -_SCRAPE_CADASTRE_AVAILABLE = ( - importlib.util.find_spec("app.workers.tasks.scrape_cadastre") is not None -) - -requires_scrape_cadastre = pytest.mark.skipif( - not _SCRAPE_CADASTRE_AVAILABLE, - reason=( - "app.scrapers.nspd_bulk_client отсутствует — нужно смержить main (PR #170). " - "Команда: git rebase origin/main" - ), -) - -# Токен для тестов (не реальный) -ADMIN_TOKEN = "test-admin-token" -ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} - def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None): """Создать mock db session с преднастроенными ответами.""" @@ -113,8 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]: # ── Tests ──────────────────────────────────────────────────────────────────── -@requires_scrape_cadastre -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_pilot_returns_job_id() -> None: """POST /cadastre/jobs scope=pilot → job_id + targets_total.""" quarters_50 = [f"66:41:{i:07d}" for i in range(50)] @@ -131,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None: response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "pilot"}, - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text @@ -144,8 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None: app.dependency_overrides.clear() -@requires_scrape_cadastre -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list() -> None: """POST /cadastre/jobs scope=manual_list с явным списком.""" db = _make_mock_db() @@ -164,7 +139,6 @@ def test_create_job_manual_list() -> None: "scope": "manual_list", "quarters": ["66:41:0303161", "66:41:0303162"], }, - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text @@ -174,8 +148,6 @@ def test_create_job_manual_list() -> None: app.dependency_overrides.clear() -@requires_scrape_cadastre -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list_empty_quarters_returns_400() -> None: """scope=manual_list без quarters → 400.""" db = _make_mock_db() @@ -189,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None: response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "manual_list"}, - headers=ADMIN_HEADERS, ) assert response.status_code == 400 finally: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_list_jobs_returns_list() -> None: """GET /cadastre/jobs → список jobs.""" db = _make_mock_db(job_row=_make_sample_job_row()) @@ -213,7 +183,7 @@ def test_list_jobs_returns_list() -> None: try: client = TestClient(app) - response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS) + response = client.get("/api/v1/admin/cadastre/jobs") assert response.status_code == 200, response.text body = response.json() assert isinstance(body, list) @@ -225,7 +195,6 @@ def test_list_jobs_returns_list() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_get_job_not_found_returns_404() -> None: """GET /cadastre/jobs/9999 → 404.""" db = MagicMock() @@ -239,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None: try: client = TestClient(app) - response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS) + response = client.get("/api/v1/admin/cadastre/jobs/9999") assert response.status_code == 404 finally: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_success() -> None: """POST /cadastre/jobs/42/cancel → {cancelled: true}.""" db = MagicMock() @@ -262,7 +230,6 @@ def test_cancel_job_success() -> None: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/42/cancel", - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text body = response.json() @@ -272,7 +239,6 @@ def test_cancel_job_success() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_not_found_returns_404() -> None: """POST /cadastre/jobs/9999/cancel когда job не найден → 404.""" db = MagicMock() @@ -289,27 +255,7 @@ def test_cancel_job_not_found_returns_404() -> None: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/9999/cancel", - headers=ADMIN_HEADERS, ) assert response.status_code == 404 finally: app.dependency_overrides.clear() - - -@pytest.mark.skip( - reason=( - "X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — " - "Caddy basic_auth + RBAC middleware достаточны). Admin endpoint'ы больше не " - "несут verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode " - "RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без " - "реверта security-решения #437. Тест проверял удалённое поведение." - ) -) -def test_create_job_no_token_returns_401() -> None: - """Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437).""" - client = TestClient(app) - response = client.post( - "/api/v1/admin/cadastre/jobs", - json={"scope": "pilot"}, - ) - assert response.status_code in (401, 503) diff --git a/backend/tests/api/v1/test_admin_ekburg_permits.py b/backend/tests/api/v1/test_admin_ekburg_permits.py index 7c75f376..45d9bd85 100644 --- a/backend/tests/api/v1/test_admin_ekburg_permits.py +++ b/backend/tests/api/v1/test_admin_ekburg_permits.py @@ -4,7 +4,11 @@ - валидный запрос без year → scope all_years_2022_2026, task_id в ответе - валидный запрос с year=2026 → scope year_2026 - year < 2022 или > 2030 → 422 -- отсутствие X-Admin-Token → 401/503 + +Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие +X-Admin-Token → 401/503» стояла в этом докстринге, но соответствующего теста в +файле нет. Гейт `/api/v1/admin/*` — middleware `rbac_guard` (app/main.py), +покрыт tests/test_rbac.py. """ from __future__ import annotations @@ -16,8 +20,6 @@ from fastapi.testclient import TestClient from app.main import app -ADMIN_TOKEN = "test-admin-token" -ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} ENDPOINT = "/api/v1/admin/scrape/ekburg-permits" @@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock: return result -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_refresh_all_returns_task_id() -> None: """POST без year → refresh_all queued, scope=all_years_2022_2026.""" mock_result = _mock_task("task-all-001") @@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None: ): mock_refresh_all.apply_async.return_value = mock_result client = TestClient(app) - response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={}) assert response.status_code == 200, response.text body = response.json() @@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None: assert "queued_at" in body -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_refresh_year_returns_task_id() -> None: """POST year=2026 → refresh_year queued, scope=year_2026.""" mock_result = _mock_task("task-year-002") @@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None: ): mock_refresh_year.apply_async.return_value = mock_result client = TestClient(app) - response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={"year": 2026}) assert response.status_code == 200, response.text body = response.json() @@ -67,25 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None: @pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999]) -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_invalid_year_returns_422(bad_year: int) -> None: """year вне диапазона [2022, 2030] → 422 Unprocessable Entity.""" client = TestClient(app) - response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={"year": bad_year}) assert response.status_code == 422, f"year={bad_year} должен возвращать 422" - - -@pytest.mark.skip( - reason=( - "X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — " - "Caddy basic_auth + RBAC middleware достаточны). trigger_ekburg_permits больше не " - "несёт verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode " - "RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без " - "реверта security-решения #437. Тест проверял удалённое поведение." - ) -) -def test_trigger_no_token_returns_401_or_503() -> None: - """Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437).""" - client = TestClient(app) - response = client.post(ENDPOINT, json={}) - assert response.status_code in (401, 503), response.text diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index ad7a2248..cc61f0ef 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None: finally: app.dependency_overrides.clear() _stop_patches() + + +def test_analyze_missing_profile_is_not_labelled_profile() -> None: + """#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'. + + Три способа промахнуться мимо профиля (все три воспроизведены живым запросом + на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id. + В mock-БД профилей нет — значит применились системные веса, и ответ обязан + это признать, а не утверждать, что считал по профилю. + """ + from app.core.db import get_db + from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS + + for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"): + db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}") + assert resp.status_code == 200, resp.text + wp = resp.json()["weights_profile"] + # sanity: веса и правда системные, промах реальный + assert wp["weights_applied"]["tram_stop"] == pytest.approx( + _SYSTEM_POI_WEIGHTS["tram_stop"] + ) + assert wp["source"] != "profile", ( + f"?{qs}: применились системные веса, а метка source='profile' — " + "ответ утверждает то, чего не было (#2811)" + ) + assert wp["source"] == "system" + # «что просили» не теряется: запрошенный id + явный признак промаха + assert wp["profile_id"] == 999999 + assert wp["requested_profile_applied"] is False + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_found_profile_keeps_label_and_flag() -> None: + """Обратная сторона: профиль найден → source='profile', флаг промаха False.""" + from datetime import UTC, datetime + + import app.services.site_finder.weight_profiles as wp_module + from app.core.db import get_db + from app.services.site_finder.weight_profiles import WeightProfile + + profile = WeightProfile( + id=7, + user_id="user-1", + profile_name="test", + weights={"tram_stop": -0.4}, + is_default=False, + description=None, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + original = wp_module.get_profile + wp_module.get_profile = lambda _db, uid, pid: profile + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1") + assert resp.status_code == 200, resp.text + wp = resp.json()["weights_profile"] + assert wp["source"] == "profile" + assert wp["requested_profile_applied"] is True + assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4) + finally: + wp_module.get_profile = original + app.dependency_overrides.clear() + _stop_patches() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4a5fb6f2..a3266cb6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок ` from __future__ import annotations import os +from pathlib import Path import pytest @@ -40,3 +41,70 @@ def _clear_dependency_overrides(): app.dependency_overrides.clear() except Exception: pass + + +# ── Бюджет пропусков (#2745) ────────────────────────────────────────────────── +# +# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта: +# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и +# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI +# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть +# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в +# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал +# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно. +# +# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и +# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не +# ошибка; ошибка — пропуск без записи. +# +# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без +# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить. +# +# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона, +# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких +# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml). + +_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt" +_observed_skips: set[str] = set() + + +def _allowed_skips() -> set[str]: + if not _SKIP_ALLOWLIST_PATH.exists(): + return set() + lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines() + return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())} + + +def _record_skip(report) -> None: + if report.skipped: + # nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может + # отсутствовать, а не каждую её параметризацию. + _observed_skips.add(report.nodeid.split("[", 1)[0]) + + +def pytest_runtest_logreport(report) -> None: + _record_skip(report) + + +def pytest_collectreport(report) -> None: + # Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в + # logreport, и nodeid у него — путь файла. + _record_skip(report) + + +def pytest_sessionfinish(session, exitstatus) -> None: + unlisted = sorted(_observed_skips - _allowed_skips()) + if not unlisted: + return + print( + f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" + ) + for nodeid in unlisted: + print(f" - {nodeid}") + print( + "Почини тест либо внеси его в skip_allowlist.txt с причиной — " + "пропуск без записи неотличим от пройденной проверки." + ) + if exitstatus == 0: + session.exitstatus = 1 diff --git a/backend/tests/services/scrapers/test_ekb_krt_registry.py b/backend/tests/services/scrapers/test_ekb_krt_registry.py index 2463fc8b..9972e217 100644 --- a/backend/tests/services/scrapers/test_ekb_krt_registry.py +++ b/backend/tests/services/scrapers/test_ekb_krt_registry.py @@ -197,32 +197,74 @@ class TestParseBabushkinaBlock: assert site.krt_kind == "жилой застройки" -class TestMultiSiteCountFromDump: - """Проверка количества площадок из реального дампа страницы.""" +# ── Многоблочная страница: два года, три площадки, повторяющийся номер ──────── +# +# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший +# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у +# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а +# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому +# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ. +# +# Свойство, ради которого они писались, от дампа не зависит и выражается +# страницей из блоков, которые уже лежат в этом файле: +# • несколько

-секций с годами разбираются в одном проходе; +# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ, +# поэтому «2.» встречается дважды — и site_name всё равно обязан быть +# уникальным (номер сам по себе ключом не является); +# • блоки лежат внутри контейнера
— это ветка `_extract_krt_section` +# через `find_parent`, которой одиночные блоки выше не касаются вовсе. +# +# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778): +# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора +# реального документа, синтетической страницей оно не заменяется. На проде +# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но +# автоматической проверки этого больше нет — она вернётся, когда в репозитории +# появится образец страницы. +_MULTISITE_PAGE = ( + "
" + + _BRUSNIKA_BLOCK + + _BABUSHKINA_BLOCK + + textwrap.dedent( + """\ +

 2. КРТ нежилой застройки в районе ул. Краснофлотцев:

+

Площадь территории: 3,65 га;
\ +Градостроительный потенциал: 117 640 кв. м;
\ +Всего многоквартирных домов: 13.

+ """ + ) + + "
" +) + + +class TestMultiSitePage: + """Разбор страницы из нескольких блоков — без локального дампа.""" @pytest.fixture(scope="class") - def dump_html(self): - """Загружает локальный дамп страницы. + def sites(self): + return parse_krt_page(_MULTISITE_PAGE) - Тест пропускается если файл отсутствует (CI без дампа). - """ - import os + def test_all_three_sites_parsed(self, sites): + """Три блока в двух годовых секциях → три площадки.""" + assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}" - dump_path = os.path.join( - os.path.dirname(__file__), - "../../../../.playwright-mcp/ekb-docs/krt_page_raw.html", - ) - if not os.path.exists(dump_path): - pytest.skip("krt_page_raw.html dump not found — skip real-dump test") - with open(dump_path, encoding="utf-8") as fh: - return fh.read() - - def test_site_count_from_dump(self, dump_html): - """Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15).""" - sites = parse_krt_page(dump_html) - assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}" - - def test_no_duplicate_site_names(self, dump_html): - sites = parse_krt_page(dump_html) + def test_no_duplicate_site_names(self, sites): + """Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны.""" names = [s.site_name for s in sites] - assert len(names) == len(set(names)), "Дубликаты site_name в результате" + assert sum(n.startswith("2.") for n in names) == 2, names + assert len(names) == len(set(names)), f"Дубликаты site_name: {names}" + + def test_blocks_do_not_leak_into_each_other(self, sites): + """Границы блоков соблюдены: договор и застройщик остались у своей площадки. + + Регресс, который этим ловится: если block_end съезжает, «Брусника» и + договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему. + """ + with_developer = [s for s in sites if s.developer_name] + assert len(with_developer) == 1, [s.developer_name for s in sites] + assert "Брусника" in with_developer[0].developer_name + assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1 + + def test_kinds_are_read_per_block(self, sites): + """Третий блок — «нежилой застройки», не должен унаследовать вид соседа.""" + kinds = sorted(s.krt_kind for s in sites if s.krt_kind) + assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds diff --git a/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py b/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py index 292f3653..662e76a7 100644 --- a/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py +++ b/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py @@ -202,9 +202,7 @@ class TestParseTable11: # Реальная зона должна быть в результате assert "Зона жилой застройки" in zone_names # Артефакты (zone_name пуст, не is_total) отброшены - non_total_no_zone = [ - r for r in result if not r["zone_name"] and not r.get("is_total") - ] + non_total_no_zone = [r for r in result if not r["zone_name"] and not r.get("is_total")] assert non_total_no_zone == [] # Итого-строка осталась assert any(r.get("is_total") for r in result) @@ -483,15 +481,23 @@ class TestParsePptTepMocked: # ── Тест на реальном PDF-образце (skip в CI) ───────────────────────────────── - -_REAL_PDF_PATH = Path( - "C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf" +# +# Путь repo-relative, а не абсолютный `C:/Users/user/source/repos/...`: с +# Windows-путём условие пропуска не выполнялось НИ НА ОДНОЙ машине кроме одного +# старого дев-бокса, т.е. эти 6 тестов были не «пропущены по среде», а мертвы. +# Образец не в git (untracked `.playwright-mcp/`), поэтому в CI пропуск остаётся — +# но теперь он снимается тем, что файл кладут в репо-каталог на любой ОС. +_REAL_PDF_PATH = ( + Path(__file__).resolve().parents[4] + / ".playwright-mcp" + / "ekb-docs" + / "ppt2018_22823_poyasnit_PPT.pdf" ) @pytest.mark.skipif( not _REAL_PDF_PATH.exists(), - reason="Реальный PDF-образец отсутствует (только для локального запуска)", + reason=f"Реальный PDF-образец отсутствует ({_REAL_PDF_PATH}) — только локальный запуск", ) class TestParsePptTepRealPdf: """Интеграционные тесты на реальном образце ppt2018_22823.""" diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index f04c2322..c5cab6f1 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -10,8 +10,10 @@ DNS-fail повторяет timeout на каждый analyze. 3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две раздельные таблицы внутри модуля). - 4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start → - ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store). + 4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на + поток, все получают одно и то же значение, и шторм заканчивается сложившимся + кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно + (#1370, см. сам тест). 5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at → следующий вызов идёт по сети заново (а не из устаревшего кэша). @@ -23,6 +25,7 @@ from __future__ import annotations import os import threading +import time from collections.abc import Iterator from typing import Any from unittest.mock import MagicMock, patch @@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate: class TestConcurrencySafe: - def test_single_flight_cold_start_one_network_call(self) -> None: - """16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов.""" - # GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock. + def test_cold_start_storm_bounded_and_cache_converges(self) -> None: + """16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток, + все получают одно и то же значение, и после шторма кэш отвечает без сети. + + ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это + было требование, которого код НЕ выполняет и выполнять не собирается: + сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze + сериализуются на время httpx-вызова даже для разных координат), а рядом с + ним написано, что cold-start на один ключ «может породить несколько + параллельных запросов… приемлемо». Тест зеленел не потому, что защита + работает, а потому что при GIL первый поток обычно успевал сложить + результат раньше остальных. + + Замер 2026-08-07, 200 штормов подряд: при дефолтном + `sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые + ~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов, + было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда + потоки реально чередуются, больше одного вызова дали 197 штормов из 200, + и в 173 из них вызовов было все 16. То есть утверждение ложно почти + всегда, когда гонка вообще случается, — чинить надо было тест. + + Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ + стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние + запросы бывают только на cold-start одного ключа и они идемпотентны. + Понадобится — это отдельная задача с отдельным обоснованием, а не + побочный эффект правки теста. + + `time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти + промах кэша до первой записи. Так тест мерит худший случай той самой + уступки, а не везение планировщика. + """ start_barrier = threading.Barrier(16) get_call_count = 0 get_lock = threading.Lock() @@ -253,8 +284,7 @@ class TestConcurrencySafe: nonlocal get_call_count with get_lock: get_call_count += 1 - # Микро-задержка — окно для других потоков добраться до lock'а. - # Не делаем sleep большим, чтобы тест не висел. + time.sleep(0.05) # окно, в котором остальные потоки видят промах return _make_httpx_response(_make_forecast_response()) client_ctx = MagicMock() @@ -276,11 +306,27 @@ class TestConcurrencySafe: t.start() for t in threads: t.join() + storm_calls = get_call_count + # Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что + # поход в сеть был бы виден счётчиком, а не отказом коннекта. + after_storm = weather_cache.get_weather_cached(56.84, 60.59) assert len(results) == 16 - assert all(r is not None for r in results) - # Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов. - assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}" + assert results[0] is not None + assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения" + # Потолок — число участников: в сеть идут только промахнувшиеся, по разу + # каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная + # запись в кэш); меньше единицы невозможно, кэш был пуст. + assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках" + # Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз: + # следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен + # снятого single-flight — без этого уступка превращается в дыру. + assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)] + assert after_storm == results[0] + assert get_call_count == storm_calls, ( + f"после шторма кэш обязан отвечать без сети, а вызовов стало " + f"{get_call_count} против {storm_calls}" + ) # ────────────────────────────────────────────────────────────────────────────── diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt new file mode 100644 index 00000000..02ca21bc --- /dev/null +++ b/backend/tests/skip_allowlist.txt @@ -0,0 +1,97 @@ +# Объявленные пропуски сьюта backend/. +# +# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не +# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала +# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда, +# когда на неё понадобилось опереться (#2722, #2729, #2740). +# +# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий. +# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ +# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра. +# +# Список — НАДмножество сред: на CI (ubuntu + libpango + postgres-сервис) часть +# записей не срабатывает, на ноутбуке без них — срабатывает. Запись, чей пропуск +# не случился, безвредна; пропуск без записи — нет. + +# ── WeasyPrint: нужны native-либы (Pango/cairo/GObject) ─────────────────────── +# В CI ЭТИ ТЕСТЫ ИДУТ: ci.yml ставит libcairo2/libpango-1.0-0/libpangoft2-1.0-0. +# Записи нужны только для macOS-dev, где libgobject-2.0-0 нет. +tests/api/v1/test_parcels_forecast.py::test_export_forecast_pdf_returns_pdf +tests/services/exporters/test_report_pdf.py::TestConfidenceSectionParity::test_dict_shaped_factor_produces_valid_pdf +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_accepts_as_dict_input +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_non_trivial_length +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_pdf_magic_bytes +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_empty_report_still_valid_pdf +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_garbage_input_does_not_crash +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_partial_report_some_sections +tests/services/generative/test_exporters.py::test_pdf_export_produces_pdf_bytes +tests/test_layout_tz_pdf.py + +# ── phantom-column gate: нужна КОПИЯ ПРОДОВОЙ схемы ─────────────────────────── +# Смысл этих тестов — поймать колонку, которой нет в реальной БД, поэтому пустой +# сервис-контейнер их не заменяет: против пустой схемы они дали бы красноту, не +# относящуюся к делу. Условие: TEST_DATABASE_URL на SSH-туннель к проду +# (`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::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 +tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_supply_batch_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_full_cte_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_radius_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_velocity_competitor_query_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rns_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rve_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_recent_permits_query +tests/integration/test_phantom_columns.py::TestMvLayoutVelocity::test_velocity_by_room_bucket +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_corpus_room_month_columns +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_mapping_columns +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_sold_count_query + +# ── SQL-логика на живой Postgres ────────────────────────────────────────────── +# В CI ЭТИ ТЕСТЫ ИДУТ с #2745 (postgres-сервис в ci.yml); до него не бежали ни +# разу с момента написания. Схема не нужна — строят себе временные таблицы. +# Локально: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:16 +tests/sql/test_ddu_price_indicator.py::test_basis_and_previous_index +tests/sql/test_ddu_price_indicator.py::test_packaged_deal_bucketed_by_per_unit_area +tests/sql/test_ddu_price_indicator.py::test_prev_period_value_skips_filtered_quarter +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_hand_computed_weighted_average +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_no_zero_months_weighted_equals_naive +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_nullif_prevents_division_by_zero +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_weighted_differs_from_naive_sparse_project +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_zero_deal_months_skew_naive_avg +tests/sql/test_velocity_alerts.py::test_lookback_anchors_to_latest_data_month +tests/sql/test_velocity_alerts.py::test_sharp_drop_is_flagged + +# ── Живая сеть NSPD (nspd.gov.ru) ───────────────────────────────────────────── +# Ходят в внешний сервис: в гейте это флейк и зависимость от чужого аптайма. +# Запуск вручную: uv run pytest tests/scrapers/ -m slow / -m integration -s +tests/scrapers/test_nspd_bulk_client.py::test_list_objects_in_building_real +tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_empty +tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb +tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real +tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped + +# ── Образец, которого нет в git ─────────────────────────────────────────────── +# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py:: +# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не +# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит +# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), — +# зато свойство, которое они проверяли (несколько блоков на странице, уникальные +# site_name), выразимо без дампа и теперь проверяется на многоблочной странице, +# собранной из блоков, уже лежащих в том же тест-файле. +# +# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF +# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком. +# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) — +# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить +# нет: официальный градостроительный документ, без персональных данных. +# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`, +# убрать эти шесть строк. +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_tep_has_rows +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_itogo +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_rows diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py index da53c58d..5d3bd7fc 100644 --- a/backend/tests/test_admin_weight_profiles.py +++ b/backend/tests/test_admin_weight_profiles.py @@ -6,9 +6,15 @@ - GET /{id} → 200 / 404 - PUT /{id} → 200 / 404 - DELETE /{id} → 204 / 404 -- 401 при отсутствии X-Admin-Token - 422 при невалидных weights (неизвестная категория, вес вне диапазона) +Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при +отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в +файле нет — заголовок просто отправлялся во все запросы и никем не читался +(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware +`rbac_guard` (app/main.py, `_ADMIN_API_RE` → 403 для role != admin) и покрыт +tests/test_rbac.py. + Mock-based: get_db переопределяется через dependency override. """ @@ -24,9 +30,6 @@ from app.core.db import get_db from app.main import app from app.services.site_finder.weight_profiles import WeightProfile -_ADMIN_TOKEN = "test-admin-token" -_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN} - _NOW = datetime.now(UTC) @@ -50,9 +53,7 @@ def _make_profile( @pytest.fixture() -def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient: - """TestClient с переопределённым SCRAPE_ADMIN_TOKEN.""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) +def client() -> TestClient: return TestClient(app) @@ -76,7 +77,7 @@ def _clear_overrides(): # ── GET list ─────────────────────────────────────────────────────────────────── -def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + пустой список.""" mock = MagicMock() _override_db(mock) @@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: [], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-x"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json() == [] @@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat _clear_overrides() -def test_list_returns_profiles( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + список профилей.""" profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")] mock = MagicMock() @@ -108,10 +106,9 @@ def test_list_returns_profiles( "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: profiles, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 body = r.json() @@ -124,7 +121,7 @@ def test_list_returns_profiles( # ── POST create ──────────────────────────────────────────────────────────────── -def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """POST создаёт профиль, возвращает его со статусом 201.""" created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5}) mock = MagicMock() @@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk "app.api.v1.admin_weight_profiles.create_profile", lambda db, payload: created, ) - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", @@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk "weights": {"school": 2.0, "park": 1.5}, "is_default": False, }, - headers=_HEADERS, ) assert r.status_code == 201 body = r.json() @@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk _clear_overrides() -def test_create_validation_unknown_category(client_with_token: TestClient) -> None: +def test_create_validation_unknown_category(client: TestClient) -> None: """POST с неизвестной POI-категорией → 422 (Pydantic validation).""" - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES }, - headers=_HEADERS, ) assert r.status_code == 422 -def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None: +def test_create_validation_weight_out_of_bounds(client: TestClient) -> None: """POST с весом вне [-2, 3] → 422.""" - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"school": 99.0}, }, - headers=_HEADERS, ) assert r.status_code == 422 @@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) - # ── GET one ──────────────────────────────────────────────────────────────────── -def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id}?user_id= → 200.""" profile = _make_profile(7) mock = MagicMock() @@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: profile, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles/7", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["id"] == 7 @@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo _clear_overrides() -def test_get_profile_not_found( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id} несуществующего профиля → 404.""" mock = MagicMock() _override_db(mock) @@ -216,10 +207,9 @@ def test_get_profile_not_found( "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: None, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -229,7 +219,7 @@ def test_get_profile_not_found( # ── PUT update ───────────────────────────────────────────────────────────────── -def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} → 200 + обновлённый профиль.""" updated = _make_profile(3, profile_name="Обновлённый") mock = MagicMock() @@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: updated, ) - r = client_with_token.put( + r = client.put( "/api/v1/admin/site-finder/weight-profiles/3", params={"user_id": "user-1"}, json={"profile_name": "Обновлённый"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["profile_name"] == "Обновлённый" @@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke _clear_overrides() -def test_update_profile_not_found( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) @@ -262,11 +249,10 @@ def test_update_profile_not_found( "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: None, ) - r = client_with_token.put( + r = client.put( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, json={"profile_name": "X"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -276,7 +262,7 @@ def test_update_profile_not_found( # ── DELETE ───────────────────────────────────────────────────────────────────── -def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} → 204.""" mock = MagicMock() _override_db(mock) @@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: True, ) - r = client_with_token.delete( + r = client.delete( "/api/v1/admin/site-finder/weight-profiles/5", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 204 finally: _clear_overrides() -def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) @@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: False, ) - r = client_with_token.delete( + r = client.delete( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon def test_list_include_system_calls_with_system( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch + client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets.""" system_profile = _make_profile( @@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: [user_profile, system_profile], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1", "include_system": "true"}, - headers=_HEADERS, ) assert r.status_code == 200 body = r.json() @@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system( def test_list_without_include_system_does_not_call_with_system( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch + client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET без include_system → list_profiles (только пользовательские профили).""" user_profile = _make_profile(1, user_id="user-1") @@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: called_with_system.append(True) or [], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 assert len(r.json()) == 1 @@ -399,41 +381,3 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N assert len(captured_params) == 1 assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID assert captured_params[0]["user_id"] == "user-test" - - -# ── Auth ─────────────────────────────────────────────────────────────────────── - - -_TOKEN_REMOVED_REASON = ( - "App-level X-Admin-Token header удалён 2026-05-23 (см. docstring " - "app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, " - "двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. " - "В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта " - "security-решения. Тест проверял удалённое поведение." -) - - -@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) -def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None: - """Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) - client = TestClient(app) - r = client.get( - "/api/v1/admin/site-finder/weight-profiles", - params={"user_id": "user-1"}, - # без headers — нет X-Admin-Token - ) - assert r.status_code == 401 - - -@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) -def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None: - """Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) - client = TestClient(app) - r = client.get( - "/api/v1/admin/site-finder/weight-profiles", - params={"user_id": "user-1"}, - headers={"X-Admin-Token": "wrong-token"}, - ) - assert r.status_code == 401 diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index 75538a31..19ff11d1 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -2,14 +2,27 @@ Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init() только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, -и что scrub_sensitive_query redact-ит api keys из URL spans. +что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event +redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/ +company/message) из request.data/extra/contexts (#2457-review), и — в конце +файла — что до транспорта не доезжают ни ПДн тела запроса, ни значения +локальных переменных кадра стека, ни второе событие о сбое самого скраба +(#2753, поведение через подставной транспорт вместо поиска подстроки). """ +import json import os +import pathlib +import subprocess +import sys +from functools import lru_cache from unittest.mock import patch +import pytest import sentry_sdk +_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1] + def test_sdk_imports_without_error() -> None: """Все интеграции импортируются без ModuleNotFoundError.""" @@ -156,3 +169,331 @@ def test_scrub_handles_missing_spans() -> None: event: dict = {"request": {"url": "https://example.com"}} result = scrub_sensitive_query(event, {}) assert result["request"]["url"] == "https://example.com" + + +# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ──────────── +# +# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий +# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data, +# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk +# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором. + + +def test_scrub_pii_redacts_request_data() -> None: + """scrub_pii_event заменяет client_name/phone/email в request.data.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "client_name": "Иван Иванов", + "phone": "+79991234567", + "email": "ivan@example.com", + "address": "Екатеринбург, ул. Ленина 1", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["client_name"] == "[REDACTED]" + assert data["phone"] == "[REDACTED]" + assert data["email"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["address"] == "Екатеринбург, ул. Ленина 1" + + +def test_scrub_pii_redacts_pilot_request_company_and_message() -> None: + """scrub_pii_event заменяет company/message — свободный текст + PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают + телефоны/имена/адреса, а не только фиксированные name/phone/email + (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "company": "ООО Ромашка", + "message": "Меня зовут Иван, звоните на +79991234567", + "source": "landing", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["company"] == "[REDACTED]" + assert data["message"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["source"] == "landing" + + +def test_scrub_pii_redacts_client_prefixed_keys() -> None: + """Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) — + Птица их сегодня не использует, но одинаковый механизм на оба продукта + проще сопровождать (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "client_name": "Иван", + "client_phone": "+79991234567", + "client_email": "ivan@example.com", + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["client_name"] == "[REDACTED]" + assert extra["client_phone"] == "[REDACTED]" + assert extra["client_email"] == "[REDACTED]" + + +def test_scrub_pii_redacts_extra() -> None: + """scrub_pii_event заменяет PII-ключи в extra, не трогая остальное.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "phone": "+79990000000", + "email": "x@y.ru", + "name": "Пётр", + "lead_id": 42, + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["phone"] == "[REDACTED]" + assert extra["email"] == "[REDACTED]" + assert extra["name"] == "[REDACTED]" + assert extra["lead_id"] == 42 + + +def test_scrub_pii_is_case_insensitive() -> None: + """Сверка PII-ключей case-insensitive.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}} + result = scrub_pii_event(event, {}) + assert result["extra"]["Client_Name"] == "[REDACTED]" + assert result["extra"]["PHONE"] == "[REDACTED]" + + +def test_scrub_pii_redacts_nested_contexts() -> None: + """scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}} + result = scrub_pii_event(event, {}) + assert result["contexts"]["lead"]["email"] == "[REDACTED]" + assert result["contexts"]["trace"]["op"] == "http" + + +def test_scrub_pii_leaves_non_pii_untouched() -> None: + """scrub_pii_event не трогает event без PII-ключей.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": {"data": {"region": "66", "area_sqm": 50}}, + "extra": {"job": "geocode"}, + "level": "error", + } + result = scrub_pii_event(event, {}) + assert result["request"]["data"] == {"region": "66", "area_sqm": 50} + assert result["extra"] == {"job": "geocode"} + assert result["level"] == "error" + + +def test_scrub_pii_handles_missing_sections() -> None: + """scrub_pii_event не падает на пустом event.""" + from app.observability.sentry_scrub import scrub_pii_event + + result = scrub_pii_event({}, {}) + assert result == {} + + +def test_scrub_pii_handles_none_and_non_dict_sections() -> None: + """scrub_pii_event не бросает исключений на None/не-dict секциях.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"} + result = scrub_pii_event(event, {}) + assert result is event + + +def test_scrub_pii_returns_event_not_none() -> None: + """before_send должен вернуть event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + result = scrub_pii_event(event, {}) + assert result is not None + assert result is event + + +# ── scrub_event (composed before_send / before_send_transaction handler) ─────── +# +# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА +# канала (before_send И before_send_transaction). До #2457-review composed-хук +# висел только на before_send, а before_send_transaction оставался на голом +# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data +# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь +# через transaction-канал при glitchtip_traces_sample_rate > 0. + + +def test_scrub_event_composes_pii_and_url_secret_scrub() -> None: + """scrub_event применяет PII-scrub (ключ-based) И URL query-string secret + redact (regex) оба разом, не заменяя друг друга — разные классы данных.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = { + "request": { + "data": {"client_name": "Иван"}, + "url": "https://example.com?api_key=supersecret", + } + } + result = scrub_event(event, {}) + assert result is not None + assert result["request"]["data"]["client_name"] == "[REDACTED]" + assert "[REDACTED]" in result["request"]["url"] + assert "supersecret" not in result["request"]["url"] + + +def test_scrub_event_returns_event_not_none() -> None: + """scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_pii_event_exception() -> None: + """try/except в scrub_event — предохранитель: sentry_sdk оборачивает + before_send в capture_internal_exceptions, который при исключении ТОЛЬКО + логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал). + Если scrub_pii_event падает — scrub_event обязан вернуть event, а не + пробросить исключение дальше (#2457-review).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_pii_event", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_sensitive_query_exception() -> None: + """То же самое для второго шага композиции (URL-secret redact).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_sensitive_query", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +# ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ───── +# +# До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в +# файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут +# многострочные комментарии, где те же подстроки встречаются, — достаточно +# удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не +# говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом +# before_send (локальные переменные кадра стека уходят мимо ключевого скраба). +# +# Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе +# (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него +# доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а +# `capture_envelope` подменён до первого события. + + +@lru_cache(maxsize=2) +def _probe(module: str) -> str: + """Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку.""" + env = { + **os.environ, + "TESTING": "1", + # Синтаксически валидный DSN на несуществующий хост: init отработает, + # сети не будет даже если транспорт когда-нибудь перестанут подменять. + "GLITCHTIP_DSN": "https://probe@localhost.invalid/1", + # Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/), + # и без этого `import app` уехал бы в editable-установку пакета — то есть + # проба мерила бы чужое дерево, а не то, что рядом с ней лежит. + "PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]), + } + proc = subprocess.run( + [sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module], + cwd=_BACKEND_ROOT, + env=env, + capture_output=True, + text=True, + timeout=300, + check=False, + ) + assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}" + return proc.stdout.strip().splitlines()[-1] + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_pii_never_reaches_transport(module: str) -> None: + """Оба канала (error И transaction) отдают транспорту событие без ПДн. + + Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ + `before_send_transaction` — комментарий с теми же словами не спасает. + """ + probe = json.loads(_probe(module)) + markers = probe["markers"] + + for channel in ("error", "transaction"): + payload = probe["payloads"][channel] + # Контроль «событие вообще доехало»: без него проверка была бы зелёной + # и на пробе, которая молча ничего не отправила. + assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало" + assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал" + + leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload] + assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}" + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_local_variables_never_reach_transport(module: str) -> None: + """`include_local_variables=False`: значения локальных переменных кадра стека + не уходят в мониторинг (#2753). + + Ключевой скраб такое не ловит по построению — имя переменной произвольно, + а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()` + (в sentry-sdk он по умолчанию `True`). + """ + probe = json.loads(_probe(module)) + payload = probe["payloads"]["exception"] + + assert probe["counts"]["exception"] == 1 + assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало" + + assert ( + probe["markers"]["local_var"] not in payload + ), f"{module}: значение локальной переменной ушло в мониторинг" + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_scrub_failure_does_not_spawn_second_event(module: str) -> None: + """Сбой самого скраба не порождает ВТОРОГО события (#2753). + + `logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об + отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR), + снова попала бы в скраб, снова упала — рекурсия, защиты от которой в SDK + нет (проверено на коде до фикса: не завершается). Красный, если + `ignore_logger` убрать: обработчик войдёт повторно. + """ + probe = json.loads(_probe(module)) + + assert ( + probe["scrub_handler_entries"] == 1 + ), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг" + assert probe["counts"]["scrub_failure"] == 1 diff --git a/backend/tests/test_weight_profiles.py b/backend/tests/test_weight_profiles.py index 9cbb97c0..87b62883 100644 --- a/backend/tests/test_weight_profiles.py +++ b/backend/tests/test_weight_profiles.py @@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет: - resolve_weights: нет user_id и profile_id → системные дефолты - resolve_weights: user_id задан, default-профиль есть → его веса - resolve_weights: profile_id задан → его веса -- resolve_weights: профиль не найден → системные дефолты (fallback) +- resolve_weights: профиль не найден → системные дефолты (fallback) + source != profile """ from __future__ import annotations +import logging from unittest.mock import MagicMock import pytest @@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None: """Оба аргумента None → возвращаются системные веса.""" db = MagicMock() result = resolve_weights(db, user_id=None, profile_id=None) - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + assert result.source == "system" # db не должен вызываться вообще db.execute.assert_not_called() @@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None: """Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS.""" db = MagicMock() result = resolve_weights(db, user_id=None, profile_id=None) - result["school"] = 999.0 + result.weights["school"] = 999.0 # Оригинал не изменён assert _SYSTEM_POI_WEIGHTS["school"] == 1.5 @@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None: finally: wp_module.get_default_profile = original - assert result == custom_weights + assert result.weights == custom_weights + assert result.source == "user_default" def test_resolve_weights_uses_specific_profile() -> None: @@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None: finally: wp_module.get_profile = original - assert result == custom_weights + assert result.weights == custom_weights + assert result.source == "profile" def test_resolve_weights_profile_not_found_fallback() -> None: @@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None: wp_module.get_profile = original_get wp_module.get_default_profile = original_default - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + # #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли + assert result.source == "system" def test_resolve_weights_empty_profile_weights_fallback() -> None: @@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None: finally: wp_module.get_default_profile = original_default - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + assert result.source == "system" + + +def test_resolve_weights_profile_id_without_owner_is_not_profile( + caplog: pytest.LogCaptureFixture, +) -> None: + """#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется. + + Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 — + source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он + -0.4). Метка обязана быть 'system', а промах — попасть в warning. + """ + db = MagicMock() + with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"): + result = resolve_weights(db, user_id=None, profile_id=1) + + assert result.source == "system" + assert result.weights == _SYSTEM_POI_WEIGHTS + assert "profile_id=1" in caplog.text + db.execute.assert_not_called() # профиль даже не искали + + +def test_resolve_weights_missing_profile_falls_to_user_default_not_profile( + caplog: pytest.LogCaptureFixture, +) -> None: + """#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль. + + Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не + видна. Метка должна сказать 'user_default', а не 'profile'. + """ + import app.services.site_finder.weight_profiles as wp_module + + default_profile = _make_profile_mock({"school": 2.0}) + db = MagicMock() + original_get = wp_module.get_profile + original_default = wp_module.get_default_profile + wp_module.get_profile = lambda _db, uid, pid: None + wp_module.get_default_profile = lambda _db, uid: default_profile + try: + with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"): + result = resolve_weights(db, user_id="user-1", profile_id=999) + finally: + wp_module.get_profile = original_get + wp_module.get_default_profile = original_default + + assert result.source == "user_default" + assert result.weights == {"school": 2.0} + assert "profile_id=999" in caplog.text diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 768c2af9..9e04bd6b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,10 +17,66 @@ # # Postgres + Redis run alongside the app on the same VM (Discovery mode). # Volumes are shared with docker-compose.yml so switching between files preserves data. +# +# ── logging: journald (#2761) ──────────────────────────────────────────────── +# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ +# и живёт в /var/lib/docker/containers// (умирает вместе с контейнером). +# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН +# способ на обе половины, расхождение двух стеков дороже в поддержке. +# +# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера): +# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека +# postgres 2.2 ← 162 МБ за 74 дня +# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5 +# ИТОГО ~39 МБ/сутки +# Бюджет journald (замер там же, сообщение самого systemd-journald): +# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён +# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G). +# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки +# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины. +# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с +# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс +# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ. +# +# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть): +# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер +# # История через пересоздания: журнал принадлежит root:systemd-journal, а +# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и +# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт +# # "No entries". Рабочий однострочник — через docker-группу: +# docker run --rm -v /:/host:ro alpine chroot /host sh -c \ +# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"' +# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста +# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760). +# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID= +# # — так читается лог УЖЕ УДАЛЁННОГО контейнера). +# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl +# заработает напрямую (host-config, не этот файл). До этого правка не регрессия. +# +# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так +# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так +# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки: +# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка +# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без +# ночных прогонов. +# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при +# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker +# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это +# host drop-in journald.conf.d, не этот файл. +# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями. +# +# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной +# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры +# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size. +x-logging: &default-logging + driver: journald + options: + tag: "{{.Name}}" services: postgres: image: postgis/postgis:16-3.4 + logging: *default-logging restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB} @@ -46,6 +102,7 @@ services: redis: image: redis:7-alpine + logging: *default-logging restart: unless-stopped volumes: - redis_data:/data @@ -54,6 +111,40 @@ services: interval: 10s timeout: 3s retries: 5 + # #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ + # его достать. До этого redis жил только в gendesign_default, а tradein — в + # gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не + # резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а + # отсутствующая связность (см. #2709). + # + # Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml + # поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES — + # ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый + # сервис в tradein-compose в этот список не попадает и `--no-deps` его не + # подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал + # бы в пустоту. Правка того списка = правка deploy-tradein.yml, который + # сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком. + # + # aliases: тот же приём, что уже применён к postgres выше — стабильное имя + # gendesign-redis вместо compose-зависимого gendesign-redis-1. + # ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется + # блок networks:, неявная привязка к default пропадает, и backend/worker/ + # beat/glitchtip потеряли бы брокер (та же грабля описана у postgres). + # + # Разделение ключей — по НОМЕРУ БД, инстанс общий: + # db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей + # db1 — trade-in (SearchCache) ← вводится здесь + # db2 — glitchtip (см. REDIS_URL ниже) + # Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на + # брокере celery вытеснял бы поставленные в очередь таски. Значит tradein + # обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если + # tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая + # celery. Тогда — отдельный инстанс, а не смена политики вытеснения. + networks: + default: {} + shared: + aliases: + - gendesign-redis # OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI # вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze — @@ -76,6 +167,7 @@ services: # отдельно (docs/osrm-routing.md). osrm: image: osrm/osrm-backend:latest + logging: *default-logging restart: unless-stopped command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm volumes: @@ -103,6 +195,7 @@ services: # (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000. osrm-walk: image: osrm/osrm-backend:latest + logging: *default-logging restart: unless-stopped command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm volumes: @@ -118,6 +211,7 @@ services: backend: image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped # .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG). # required: false — compose не падает если файла нет (первый деплой). @@ -153,6 +247,7 @@ services: frontend: image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped ports: - "127.0.0.1:3000:3000" @@ -172,6 +267,7 @@ services: worker: # Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium. image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped env_file: - path: ./backend/.env @@ -201,6 +297,7 @@ services: beat: # Lean backend-образ (без Chromium) — beat только триггерит таски в Redis. image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped env_file: - path: ./backend/.env @@ -219,6 +316,7 @@ services: glitchtip-web: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-web + logging: *default-logging # profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`. # Bootstrap script activates the profile after DB + secrets are ready. # On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env. @@ -254,6 +352,7 @@ services: glitchtip-worker: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-worker + logging: *default-logging profiles: ["glitchtip"] depends_on: postgres: @@ -272,6 +371,7 @@ services: caddy: image: caddy:2 + logging: *default-logging restart: unless-stopped ports: - "80:80" @@ -310,6 +410,7 @@ services: # deploy.yml запускает: docker compose build glitchtip-auth-forwarder build: ./ops/glitchtip-auth-forwarder container_name: gendesign-auth-forwarder + logging: *default-logging restart: unless-stopped environment: GLITCHTIP_DSN: ${GLITCHTIP_DSN} diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md index 61180727..96dcfe6c 100644 --- a/docs/Secrets_Rotation_Policy.md +++ b/docs/Secrets_Rotation_Policy.md @@ -69,7 +69,7 @@ | `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) | | `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** | | `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** | -| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) | +| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — | ### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`) @@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`): --- -## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance) +## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance) -Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime». -**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436 -(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth. -Поле оставлено в `core/deps.py` только для быстрого rollback. +Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime». +**Ротировать нечего: переменной больше нет.** -**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока -`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F** -(процедура: `sed` в `backend/.env.runtime` → `up -d --force-recreate --no-deps backend beat`, -downtime отсутствует). Фактический прод-прогон ротации — операционное действие -(Anton), не выполняется в рамках этого PR. +История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран +со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и +dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до +#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему +репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте — +в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно +означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть +хранение поля не экономило ничего. Удалено вместе с `core/deps.py`, +`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах. + +Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**: +1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only` + для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста). +2. Caddy basic_auth на весь `gendsgn.ru` (PR #426). + +Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно: +`Settings.model_config` — `extra="ignore"`, лишняя переменная безвредна. `JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** — добавить в реестр (класс **F**) при внедрении JWT-аутентификации. diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx index bd7144c7..61e580a8 100644 --- a/frontend/src/app/legacy/site-finder/page.tsx +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; import { LandTab } from "@/components/site-finder/LandTab"; import { MarketTab } from "@/components/site-finder/MarketTab"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; -import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; import { useCustomPois } from "@/hooks/useCustomPois"; @@ -22,6 +22,7 @@ import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, } from "@/lib/api/weightProfiles"; +import { useMe } from "@/lib/useMe"; // SiteMap imports Leaflet which requires browser APIs — load without SSR const SiteMap = dynamic( @@ -129,16 +130,16 @@ function SiteFinderContent() { // Ref to skip the initial mount effect (we only re-analyze on actual changes). const weightsChangeInitializedRef = useRef(false); - const [profileUserId, setProfileUserId] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_user_id") ?? ""), - ); - const [adminToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); + // Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения + // из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`, + // который сервер не читает с #437) — оба недостижимы без DevTools. + // + // 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без + // `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом + // `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без + // user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль + // молча не применялся бы, а UI показывал бы его ползунки. + const profileUserId = useMe().data?.username ?? ""; // Lazy init: считаем initialTab один раз на mount (useState всё равно // игнорирует initializer после первого render — не тратим CPU). const [tab, setTabState] = useState(() => { @@ -192,12 +193,7 @@ function SiteFinderContent() { setIsochrones(undefined); mutate({ cad: currentData.cad_num, - options: - profileId != null - ? { profileId } - : currentProfileUserId - ? { profileUserId: currentProfileUserId, weights } - : { weights }, + options: buildAnalyzeOptions(weights, profileId, currentProfileUserId), }); // mutate is stable from useMutation — safe to omit from deps. // data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num @@ -216,12 +212,11 @@ function SiteFinderContent() { // slider values are always respected even without a saved profile (#201). mutate({ cad: cadNum, - options: - activeProfileId != null - ? { profileId: activeProfileId } - : profileUserId - ? { profileUserId, weights: currentWeights } - : { weights: currentWeights }, + options: buildAnalyzeOptions( + currentWeights, + activeProfileId, + profileUserId, + ), }); } @@ -329,46 +324,9 @@ function SiteFinderContent() { {/* Weight profile panel — collapsible, below header */}
- {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} - {!!adminToken && ( -
- - { - setProfileUserId(e.target.value); - if (typeof window !== "undefined") { - localStorage.setItem("admin_user_id", e.target.value); - } - }} - /> -
- )} {/* Recalculation indicator — shown while re-analyze is in-flight after weights change (data already loaded, pendingWeightsChange set). */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 03be7b0d..41cd3e4d 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast"; import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives"; -import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import { + AnalyzeWeightsContext, + adaptEgrn, + useParcelAnalyzeQuery, +} from "@/lib/site-finder-api"; +import type { PoiCategoryKey } from "@/lib/api/weightProfiles"; import type { ParcelAnalysis, PendingConceptProgram, @@ -29,7 +34,40 @@ interface Props { // ── Page Content (client — needs TanStack Query) ─────────────────────────────── +/** + * Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в + * контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние + * нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы + * контекст «сверху», то есть null, и страница разъехалась бы на два разных + * анализа — свой у шапки, свой у секций. + * + * null = веса не применяли → запрос как раньше, без тела. + */ export function AnalysisPageContent({ cad }: Props) { + const [appliedWeights, setAppliedWeights] = useState | null>(null); + + return ( + + + + ); +} + +function AnalysisPageBody({ + cad, + appliedWeights, + onWeightsApply, +}: Props & { + appliedWeights: Record | null; + onWeightsApply: (weights: Record) => void; +}) { const [horizon, setHorizon] = useState(12); const queryClient = useQueryClient(); @@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) { {/* ── Группа «Стройка и рынок» ──────────────────────────────── */} - {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */} - + {/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1 + поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор + пересчитывается по ползункам во ВСЕХ секциях (#2790). */} + {/* 5. Атмосфера — IMPLEMENTED in A11 */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx new file mode 100644 index 00000000..4cc7e35e --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx @@ -0,0 +1,159 @@ +/** + * #2790 п.1 — «Применить» у весов POI в §4.1 ничего не применяло. + * + * Состояние весов жило в `Section31Settings` и читалось только обратно в ту же + * панель: до `/analyze` оно не доезжало никогда (слова `weights` в + * AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал + * «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам. + * + * Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и + * настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес — карты, + * прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй + * POST /analyze не случается вообще → красный. + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AnalysisPageContent } from "../AnalysisPageContent"; + +// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут +// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) — +// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста. +vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null })); +vi.mock("@/components/site-finder/GateVerdictBanner", () => ({ + GateVerdictBanner: () => null, +})); +vi.mock("@/components/site-finder/HorizonSelector", () => ({ + HorizonSelector: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({ + Section1ParcelInfo: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({ + Section2NetworksUtilities: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({ + Section4Estimate: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({ + Section5Atmosphere: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({ + Section6Forecast: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({ + Section7Concept: () => null, +})); +vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({ + SectionAlternatives: () => null, +})); +vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({ + BestLayoutsBlock: () => null, +})); + +const CAD = "66:41:0702017:131"; + +const ANALYSIS = { + cad_num: CAD, + score: 18.91, + district: { district_name: "Чкаловский" }, + egrn: null, + competitors: [], +}; + +/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */ +const analyzeBodies: Array | undefined> = []; + +const fetchMock = vi.fn(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + analyzeBodies.length = 0; + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input, init) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/analyze")) { + const raw = init?.body; + analyzeBodies.push( + typeof raw === "string" + ? (JSON.parse(raw) as Record) + : undefined, + ); + return jsonResponse(ANALYSIS); + } + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +/** Ползунок конкретной категории по подписи строки в панели весов. */ +function sliderFor(label: string): HTMLInputElement { + const row = screen.getByText(label).closest("div"); + if (!row) throw new Error(`не нашёл строку ползунка «${label}»`); + const input = row.querySelector('input[type="range"]'); + if (!input) throw new Error(`в строке «${label}» нет ползунка`); + return input as HTMLInputElement; +} + +describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => { + it("отправляет ползунки в тело повторного analyze", async () => { + renderPage(); + + // Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе, + // бэкенд считает по системным. Это же и baseline для «стало другим». + await waitFor(() => expect(analyzeBodies.length).toBe(1)); + expect(analyzeBodies[0]).toBeUndefined(); + + fireEvent.click(await screen.findByText("POI Веса")); + fireEvent.change(sliderFor("Парки"), { target: { value: "3" } }); + fireEvent.change(sliderFor("Трамвайные ост. (−)"), { + target: { value: "-2" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Применить" })); + + // Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что + // выставлены ползунками. До фикса второго запроса не было — красный здесь. + await waitFor(() => expect(analyzeBodies.length).toBe(2)); + const applied = analyzeBodies[1]?.weights as Record; + expect(applied.park).toBe(3); + expect(applied.tram_stop).toBe(-2); + // Нетронутые категории уходят как есть — бэкенд мержит поверх системных, + // но панель отправляет полный набор, чтобы ответ совпадал с ползунками. + expect(applied.school).toBe(1.5); + }); +}); diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index e4c70c5f..31117249 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -10,8 +10,8 @@ /* ===================== SCOPED TOKENS (dark) ===================== */ .pticaRoot[data-theme="dark"] { --font-ui: - var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", - system-ui, sans-serif; + var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui, + sans-serif; --font-mono: var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace, monospace; @@ -907,31 +907,6 @@ letter-spacing: 0.08em; } -/* ===================== PLACEHOLDER PANEL ===================== */ -.placeholderPanel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - text-align: center; - min-height: 240px; - border-style: dashed; -} -.placeholderPanel .soon { - font-size: 22px; - letter-spacing: 0.12em; - color: var(--text-soft); - font-weight: 700; - text-transform: uppercase; -} -.placeholderPanel p { - font-size: 10px; - color: var(--text-soft); - margin: 0; - max-width: 280px; -} - /* ===================== STATE SCREENS ===================== */ .stateScreen { display: grid; diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 14448afb..8e9d02f9 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -526,14 +526,15 @@ function VariantPanel({ ? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " : ""} НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК - РФ); НДС начисляется только на паркинг (нежилые машиноместа). - Входной НДС по строительству уже учтён в себестоимости. Налог на - прибыль — 25% (с 2025 года). Цена продажи жилья —{" "} + РФ); НДС начисляется на нежилое — паркинг (машиноместа) и + коммерцию 1-го этажа. Входной НДС по строительству уже учтён в + себестоимости. Налог на прибыль — 25% (с 2025 года). Цена продажи + жилья —{" "} {financial.price_is_calibrated ? `калибрована по рынку (${priceSourceCaption(financial)})` : "норматив класса (нет рыночных данных по участку)"} - ; себестоимость СМР и цена паркинга — нормативные ориентиры. - Коммерческие и офисные площади не учитываются. + ; себестоимость СМР, цена паркинга и цена нежилого — нормативные + ориентиры. {financial.financing_enabled && financial.financing_is_simplified ? " Финансирование упрощено: весь кассовый разрыв покрыт " + "кредитом по ставке-нормативу, проценты капитализируются, " + @@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { value={formatMoneyCompact(financial.gross_margin_rub)} /> , profileId: number | null, ) => void; - /** - * If provided, enables save/load from DB. - * Must be non-empty for CRUD functionality. - */ - userId?: string; - /** Admin token for CRUD API calls. */ - adminToken?: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -66,12 +61,7 @@ function weightsEqual( // ── Component ───────────────────────────────────────────────────────────────── -export function WeightProfilePanel({ - currentWeights, - onWeightsChange, - userId, - adminToken, -}: Props) { +export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { const [open, setOpen] = useState(false); // Local draft weights — editable before "Применить" @@ -89,11 +79,19 @@ export function WeightProfilePanel({ const [saveName, setSaveName] = useState(""); const [saveDefault, setSaveDefault] = useState(false); - // Profiles query (only when userId + adminToken provided) - const canUseCrud = !!userId && !!adminToken; - const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? ""); + // Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили + // руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было + // взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в + // #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут + // RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется). + // В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто + // бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен. + const { data: me } = useMe(); + const userId = me?.username ?? ""; + const canUseCrud = !!userId; + const profilesQuery = useWeightProfiles(userId); - const createMutation = useCreateProfile(adminToken ?? ""); + const createMutation = useCreateProfile(); // ── Handlers ──────────────────────────────────────────────────────────────── @@ -113,11 +111,22 @@ export function WeightProfilePanel({ } function handleApply() { - onWeightsChange({ ...draft }, selectedProfileId); + // Системный пресет не адресуем через profile_id: resolve_weights() ищет + // профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не + // текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и + // отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для + // пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса, + // а они ровно те, что на ползунках. + const selected = profiles.find((p) => p.id === selectedProfileId) ?? null; + const addressableId = + selected && selected.user_id !== SYSTEM_PROFILE_USER_ID + ? selected.id + : null; + onWeightsChange({ ...draft }, addressableId); } const handleSaveProfile = useCallback(async () => { - if (!canUseCrud || !userId || !adminToken) { + if (!canUseCrud || !userId) { setShowSaveDialog(false); return; } @@ -136,15 +145,7 @@ export function WeightProfilePanel({ } catch { // Error visible through createMutation.error } - }, [ - canUseCrud, - userId, - adminToken, - saveName, - draft, - saveDefault, - createMutation, - ]); + }, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]); // ── Derived ───────────────────────────────────────────────────────────────── @@ -242,6 +243,7 @@ export function WeightProfilePanel({
@@ -268,6 +270,7 @@ export function WeightProfilePanel({ {profiles.map((p) => ( ))} @@ -278,10 +281,13 @@ export function WeightProfilePanel({
)} - {/* Hint when no crud */} + {/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт + Caddy basic_auth) — остаётся для dev-запуска без прокси, где + /api/v1/me отвечает 401. Текст называет причину, а не действие: + вводить тут больше нечего. */} {!canUseCrud && (

- Укажите User ID и Admin Token для сохранения профилей. + Пользователь не определён — сохранение профилей недоступно.

)} @@ -362,6 +368,9 @@ export function WeightProfilePanel({ }} > Новый профиль +

+ Сохранится для пользователя {userId} — другие его не увидят. +

(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +const PROFILE = { + id: 1, + user_id: "admin", + profile_name: "Мой профиль", + weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 }, + is_default: true, + description: null, + created_at: "2026-05-15T05:30:51Z", + updated_at: "2026-05-15T05:30:51Z", +}; + +function renderPanel() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {}} + /> + , + ); +} + +/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */ +async function openPanel() { + const { default: userEvent } = await import("@testing-library/user-event"); + await userEvent.setup().click(screen.getByText("POI Веса")); +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([PROFILE]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => { + it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => { + renderPanel(); + await openPanel(); + + // Профили грузятся по user_id из /me, без ручного ввода и без токена. + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.getByText("Профиль:")).toBeTruthy(); + }); + + it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.queryByText(/Admin Token/i)).toBeNull(); + expect(screen.queryByText(/Укажите User ID/i)).toBeNull(); + }); + + it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => { + const crudCall = fetchMock.mock.calls.find(([input]) => + String(input).includes("/weight-profiles"), + ); + expect(crudCall).toBeTruthy(); + const headers = new Headers( + (crudCall?.[1] as RequestInit | undefined)?.headers, + ); + expect(headers.has("X-Admin-Token")).toBe(false); + }); + }); +}); + +describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => { + const weights = { ...POI_DEFAULT_WEIGHTS }; + + it("выбранный профиль уходит вместе с profileUserId", () => { + expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({ + profileId: 7, + profileUserId: "admin", + }); + }); + + it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => { + const options = buildAnalyzeOptions(weights, 7, ""); + expect(options.profileId).toBeUndefined(); + expect(options.weights).toEqual(weights); + }); + + it("без профиля уходят inline-веса и владелец для default-профиля", () => { + expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({ + profileUserId: "admin", + weights, + }); + }); +}); diff --git a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx deleted file mode 100644 index b72e33c9..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; - -interface AnalysisBreadcrumbProps { - cadNum: string; -} - -export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) { - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx deleted file mode 100644 index 487f5cd6..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import { ExternalLink } from "lucide-react"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface SubSection { - id: string; - label: string; -} - -interface NavSection { - id: string; - label: string; - sub?: SubSection[]; -} - -// ── Config ──────────────────────────────────────────────────────────────────── - -const NAV_SECTIONS: NavSection[] = [ - { id: "section-1", label: "1. Объект" }, - { id: "section-2", label: "2. Земля и риски" }, - { - id: "section-3", - label: "3. Рынок", - sub: [ - { id: "section-3-1", label: "3.1 Настройки выборки" }, - { id: "section-3-2", label: "3.2 Планировки" }, - { id: "section-3-3", label: "3.3 Остатки и скорость" }, - ], - }, - { id: "section-4", label: "4. Оценка" }, - { id: "section-5", label: "5. Атмосфера" }, - { - id: "section-6", - label: "6. Прогноз", - sub: [ - { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, - { id: "section-6-2", label: "6.2 Сценарии" }, - { id: "section-6-3", label: "6.3 Уверенность" }, - { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, - { id: "section-6-5", label: "6.5 Прозрачность скоринга" }, - { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, - ], - }, -]; - -// All section IDs in scroll order (for IntersectionObserver) -const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) => - s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id], -); - -// ── Component ───────────────────────────────────────────────────────────────── - -export function AnalysisSidebar() { - const [activeId, setActiveId] = useState(ALL_SECTION_IDS[0]); - const observerRef = useRef(null); - - // Scrollspy via IntersectionObserver - useEffect(() => { - const candidates = ALL_SECTION_IDS.map((id) => - document.getElementById(id), - ).filter((el): el is HTMLElement => el !== null); - - if (candidates.length === 0) return; - - // Track which sections are visible; pick topmost visible one - const visible = new Set(); - - observerRef.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - visible.add(entry.target.id); - } else { - visible.delete(entry.target.id); - } - }); - - // Pick the topmost section that is currently visible - const next = ALL_SECTION_IDS.find((id) => visible.has(id)); - if (next) setActiveId(next); - }, - { - root: null, - // Trigger when section top enters top 60% of viewport - rootMargin: "-8px 0px -40% 0px", - threshold: 0, - }, - ); - - candidates.forEach((el) => observerRef.current!.observe(el)); - - return () => { - observerRef.current?.disconnect(); - }; - }, []); - - function handleAnchorClick( - e: React.MouseEvent, - targetId: string, - ) { - e.preventDefault(); - const el = document.getElementById(targetId); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - setActiveId(targetId); - } - - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx deleted file mode 100644 index 272c6239..00000000 --- a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx +++ /dev/null @@ -1,410 +0,0 @@ -"use client"; - -/** - * MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b, - * epic #1953). - * - * Driven by the interactive 3D MassingScene: every time the user drags the - * этажность / секций sliders, Section7Concept maps the scene's `computeModel` - * result + the analysis context into a `MassingProgram` and hands it here via - * `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and - * render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость / - * прибыль / ROI). - * - * Robustness: - * • debounce — slider drags fire many programs; only the settled one is sent. - * • latest-wins — an in-flight request is superseded by a newer one via a - * monotonic request id; a stale response is dropped, never overwriting a - * fresher result (mutateAsync + id guard, no UI flicker from out-of-order). - * • last-good — on a failed recompute we keep the last successful values and - * show a subtle inline note rather than blanking the panel. - * • skeleton — a plain grey fade KPI grid while the FIRST recompute is in - * flight (no shimmer, per ui-conventions). - * - * Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but - * this strip uses the light KPI tokens via the shared KpiCard. - */ - -import { useEffect, useRef, useState } from "react"; -import { AlertTriangle } from "lucide-react"; - -import { KpiCard } from "@/components/analytics/KpiCard"; -import { Section } from "@/components/analytics/Section"; -import { - priceSourceCaption, - useRecomputeMassing, - type FinancialModel, - type MassingProgram, - type MassingRecomputeOutput, - type Teap, -} from "@/lib/concept-api"; - -const DEBOUNCE_MS = 250; - -// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ───────── - -const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); - -/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */ -function formatMoneyCompact(rub: number): string { - const abs = Math.abs(rub); - if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`; - if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`; - return `${nf.format(Math.round(rub))} ₽`; -} - -function formatInt(n: number): string { - return nf.format(Math.round(n)); -} - -function formatPct(fraction: number): string { - return `${(fraction * 100).toFixed(1)}%`; -} - -function formatFar(far: number): string { - return far.toLocaleString("ru-RU", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); -} - -// ── KPI grid ─────────────────────────────────────────────────────────────────── - -interface KpiGridProps { - teap: Teap; - financial: FinancialModel; - /** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */ - farTarget: number; - /** True → факт-КСИТ превышает регламентный потолок (model.over). */ - ksitOver: boolean; - /** Dim the strip while a fresher recompute is in flight (last-good values). */ - stale: boolean; -} - -function KpiGrid({ - teap, - financial, - farTarget, - ksitOver, - stale, -}: KpiGridProps) { - const netPositive = - financial.net_profit_rub > 0 - ? true - : financial.net_profit_rub < 0 - ? false - : null; - - return ( -
- {/* ТЭП */} -
- - - - -
- - {/* Финмодель */} -
- - - - - 0 - ? true - : financial.npv_rub < 0 - ? false - : null, - }} - hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct( - financial.discount_rate_used, - )} годовых).`} - /> - financial.discount_rate_used - ? true - : false, - }} - hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)." - /> - -
-
- ); -} - -// ── Skeleton (grey fade, no shimmer — ui-conventions) ────────────────────────── - -function SkeletonGrid() { - const cells = Array.from({ length: 7 }); - return ( - @@ -259,8 +256,8 @@ function Section31Settings({ Профиль весов POI
@@ -769,7 +766,12 @@ function applyFilters( // ── Section 3 wrapper ───────────────────────────────────────────────────────── -export function Section3SettingsAndCompetitors({ cad, data }: Props) { +export function Section3SettingsAndCompetitors({ + cad, + data, + weights, + onWeightsApply, +}: Props) { const [filters, setFilters] = useState({ radiusKm: 2, onlyUnderConstruction: false, @@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { {/* Sub-sections */}
- + {/* Competitor table — moved before 3.2/3.3 for context */} {filteredCompetitors.length > 0 && ( diff --git a/frontend/src/components/site-finder/analysis/UserAvatar.tsx b/frontend/src/components/site-finder/analysis/UserAvatar.tsx deleted file mode 100644 index 38760a3c..00000000 --- a/frontend/src/components/site-finder/analysis/UserAvatar.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Building2 } from "lucide-react"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function getStoredOrgId(): string | null { - // Guard against SSR — localStorage not available on server - if (typeof window === "undefined") return null; - try { - return localStorage.getItem("gd_org_id"); - } catch { - return null; - } -} - -function orgInitials(orgId: string): string { - // Build a 2-letter monogram from org ID string - const parts = orgId - .toUpperCase() - .replace(/[^A-ZА-Я0-9]/gu, " ") - .split(" ") - .filter(Boolean); - if (parts.length === 0) return "??"; - if (parts.length === 1) return parts[0].slice(0, 2); - return parts[0][0] + parts[1][0]; -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export function UserAvatar() { - const [orgId, setOrgId] = useState(null); - - // Hydration-safe: read localStorage after mount - useEffect(() => { - setOrgId(getStoredOrgId()); - }, []); - - const displayLabel = orgId ?? "Demo Org"; - const initials = orgId ? orgInitials(orgId) : "DO"; - - return ( -
- {/* Avatar circle */} -
- {orgId ? ( - - {initials} - - ) : ( - - )} -
- - {/* Org name — hidden on narrow viewports via maxWidth trick */} - - {displayLabel} - -
- ); -} diff --git a/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx b/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx deleted file mode 100644 index a0a088c7..00000000 --- a/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -/** - * PticaPlaceholderPanel — honest "В разработке" panel for cockpit tabs/sections - * that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare). - */ - -import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; - -interface Props { - label: string; - hint?: string; -} - -export function PticaPlaceholderPanel({ label, hint }: Props) { - return ( -
-
{label}
- {hint &&

{hint}

} -
- ); -} diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 1ffd3a7e..745ccdb1 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; // /fetch-status every 2s, re-triggers analyze когда status=ready. export type FetchStatus = - | "ready" - | "fetching" - | "failed" - | "not_in_nspd" - | "invalid_format"; + "ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format"; export interface FetchStatusResponse { status: FetchStatus; @@ -52,6 +48,34 @@ export interface AnalyzeOptions { weights?: Record | null; } +/** + * Собрать options для POST /analyze из состояния панели весов POI. + * + * 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не + * стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id, + * profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is + * not None` не выполняется, и веса тихо падают на системные. Ответ при этом + * рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде + * #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из + * профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам. + * + * Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline + * `weights`. Они всегда равны ползункам панели, так что расхождения нет. + */ +export function buildAnalyzeOptions( + weights: Record, + profileId: number | null, + profileUserId: string, +): AnalyzeOptions { + if (profileId != null && profileUserId) { + return { profileId, profileUserId }; + } + if (profileUserId) { + return { profileUserId, weights }; + } + return { weights }; +} + /** * Custom hook для analyze flow с graceful on-demand fetch fallback. * diff --git a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts index f84f2033..81f0e074 100644 --- a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts +++ b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts @@ -16,6 +16,7 @@ * directly with a real AbortSignal and a per-URL `fetch` stub, under fake * timers, and assert on abort behaviour + the happy path. */ +import { renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── Capture the options passed to useQuery ─────────────────────────────────── @@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42"; * polling queryFn. Reads `captured.options` via a fresh binding so TS control- * flow doesn't pin it (the hook mutates it opaquely through the mock). * - * `useQuery` is fully mocked (it just records its options, no React state), so - * the rules-of-hooks invariant does not apply to this call — disable locally. + * Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые + * веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет + * dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему + * замокан и просто записывает options; провайдера над хуком нет, значит + * контекст = null, то есть ровно тот случай «весов не применяли», который этот + * тест и гоняет. */ function getQueryFn(): CapturedQueryOptions["queryFn"] { - // eslint-disable-next-line react-hooks/rules-of-hooks - useParcelAnalyzeQuery(CAD, 12); + renderHook(() => useParcelAnalyzeQuery(CAD, 12)); const options = captured.options; if (options === null) throw new Error("useQuery options not captured"); return options.queryFn; diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts index f2619dfa..1534e8ce 100644 --- a/frontend/src/lib/api/weightProfiles.ts +++ b/frontend/src/lib/api/weightProfiles.ts @@ -27,14 +27,18 @@ export interface WeightProfileCreate { description?: string | null; } -export interface WeightProfileUpdate { - profile_name?: string; - weights?: Record; - is_default?: boolean; - description?: string | null; -} - // ── Constants ───────────────────────────────────────────────────────────────── + +/** + * Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors + * `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py. + * Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`: + * `resolve_weights()` ищет профиль в области владельца, у чужого пользователя + * его не найдёт и молча вернёт системные веса с ответом `source="profile"` + * (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel. + */ +export const SYSTEM_PROFILE_USER_ID = "__system__"; + // ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES. // Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py. @@ -91,35 +95,44 @@ export const POI_WEIGHT_MAX = 3; const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles"; -function profilesHeaders(adminToken: string): HeadersInit { - return { "X-Admin-Token": adminToken }; -} +// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний +// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и +// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200 +// с корректным токеном, с мусорным и без заголовка вовсе. +// +// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены: +// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре +// (подставленный клиентом X-Authenticated-User туда же не проходит); +// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only". // ── Hooks ───────────────────────────────────────────────────────────────────── -/** List all weight profiles for a given user_id. */ -export function useWeightProfiles(userId: string, adminToken: string) { +/** + * Профили пользователя + системные пресеты (#2790). + * + * `include_system=true` домешивает в конец списка три общих пресета (Эконом / + * Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`). + * Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в + * проде с 16.05.2026 и не были видны никому. + */ +export function useWeightProfiles(userId: string) { return useQuery({ queryKey: ["weight-profiles", userId], queryFn: () => apiFetch( - `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, - { - headers: profilesHeaders(adminToken), - }, + `${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`, ), - enabled: !!userId && !!adminToken, + enabled: !!userId, }); } /** Create a new weight profile. Invalidates the list query on success. */ -export function useCreateProfile(adminToken: string) { +export function useCreateProfile() { const qc = useQueryClient(); return useMutation({ mutationFn: (payload) => apiFetch(BASE_PATH, { method: "POST", - headers: profilesHeaders(adminToken), body: JSON.stringify(payload), }), onSuccess: (_, variables) => { @@ -130,43 +143,9 @@ export function useCreateProfile(adminToken: string) { }); } -/** Update an existing weight profile by id. */ -export function useUpdateProfile( - userId: string, - profileId: number, - adminToken: string, -) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "PUT", - headers: profilesHeaders(adminToken), - body: JSON.stringify(payload), - }, - ), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); - }, - }); -} - -/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */ -export function useDeleteProfile(userId: string, adminToken: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (profileId) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "DELETE", - headers: profilesHeaders(adminToken), - }, - ), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); - }, - }); -} +// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали +// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет. +// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026, +// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни +// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда; +// понадобится UI — хуки вернутся из истории (мертвее они там не станут). diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts index a3e4ff60..e16a5fe0 100644 --- a/frontend/src/lib/concept-api.ts +++ b/frontend/src/lib/concept-api.ts @@ -330,7 +330,7 @@ export interface MassingRecomputeOutput { * rule; uses the shared `apiFetch` (base URL + session header + Content-Type). * * Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced), - * with latest-wins sequencing handled by the caller (see MassingEconomics). + * with latest-wins sequencing handled by the caller. */ export function useRecomputeMassing() { return useMutation({ diff --git a/frontend/src/lib/nspdLinks.ts b/frontend/src/lib/nspdLinks.ts index 773ada1a..7e7918d2 100644 --- a/frontend/src/lib/nspdLinks.ts +++ b/frontend/src/lib/nspdLinks.ts @@ -7,7 +7,7 @@ * и иметь один безопасный канал кодирования кадастрового номера. * * Существующие потребители паттерна (до централизации): NspdZoningBlock, - * NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar. + * NspdOpportunityBlock, ParcelDrawer. */ /** diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index ca64a76d..ebd0542b 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -9,6 +9,7 @@ */ import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { createContext, useContext } from "react"; import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api"; import { abortableSleep } from "@/lib/abortableSleep"; import type { @@ -503,9 +504,36 @@ export interface PoiScoreResponse { const ANALYZE_POLL_INTERVAL_MS = 2000; const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap +/** + * Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли → запрос + * уходит без тела, как и раньше (бэкенд считает по системным весам). + * + * Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)` + * зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) — все они делят один + * ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос. + * Если веса доедут только до части из них, ключи разойдутся: половина страницы + * покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды. + * Контекст держит всех потребителей ключа на одном значении по построению — + * забыть прокинуть проп в новую секцию нельзя. + */ +export const AnalyzeWeightsContext = createContext | null>(null); + export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { + const weights = useContext(AnalyzeWeightsContext); + // Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем. + // null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до + // #2790, кэш не сбрасывается на ровном месте. + const weightsKey = weights + ? JSON.stringify(Object.entries(weights).sort()) + : null; + return useQuery({ - queryKey: ["parcel-analyze", cad, horizon], + // Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI + // мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт. + queryKey: ["parcel-analyze", cad, horizon, weightsKey], // TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts // on unmount and whenever the queryKey changes (смена cad/horizon). Thread // it through the POST/GET fetches and check it before each poll iteration so @@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { cad, )}/analyze?horizon=${horizon}`; + // Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id: + // тело запроса == ползункам панели, и ответ рапортует source="inline" — + // расхождению между показанными весами и посчитанным скором взяться + // неоткуда (в отличие от profile_id, см. #2782). + const analyzeInit: RequestInit = weights + ? { method: "POST", signal, body: JSON.stringify({ weights }) } + : { method: "POST", signal }; + // First request — POST /analyze. apiFetchWithStatus surfaces the 202 // Accepted code instead of treating it as a successful payload. const first = await apiFetchWithStatus< ParcelAnalyzeResponse | AnalyzeAcceptedResponse - >(analyzeUrl, { method: "POST", signal }); + >(analyzeUrl, analyzeInit); // 200 → geometry was cached, full analysis is ready. if (first.status === 200) { @@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { // rather than returning the stub (symmetry with the first request). const second = await apiFetchWithStatus< ParcelAnalyzeResponse | AnalyzeAcceptedResponse - >(analyzeUrl, { method: "POST", signal }); + >(analyzeUrl, analyzeInit); if (second.status === 200) { return second.body as ParcelAnalyzeResponse; } diff --git a/scripts/check-migration-lock-timeout.py b/scripts/check-migration-lock-timeout.py new file mode 100644 index 00000000..92b1e3ff --- /dev/null +++ b/scripts/check-migration-lock-timeout.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752). + +ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала +на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за +чужой аналитической psql-сессией; вторая попытка деплоя — ещё 16 минут. Опасность +не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми +запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за +ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих +data/sql — то есть следующий блокирующий DDL повторил бы это. + +`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный +CREATE INDEX он не оборвёт, а очередь — не соберёт. Срабатывание = красный деплой +(ON_ERROR_STOP=on) вместо тихой очереди перед приложением. + +ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант +«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4: +`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW +lock_timeout` → 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX +CONCURRENTLY` — тот ждёт завершения параллельных транзакций через VirtualXactLock, +и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная +сессия просто держала открытую транзакцию (ACCESS SHARE — с CIC вообще не +конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение +изготавливало бы ровно ту аварию, от которой заведена проверка невалидных +индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят +противоположной политики, поэтому granularity — файл, а не раннер. + +ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца +сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL` +молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента. + +ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы +задним числом не переписываются. Гейт смотрит только вперёд. + +Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest] +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# каталог миграций -> минимальный NN, с которого правило обязательно. +# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая). +SQL_DIRS: dict[str, int] = { + "data/sql": 189, + "tradein-mvp/backend/data/sql": 250, +} + +# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE, +# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать +# в очередь и увести за собой запросы приложения. +BLOCKING = re.compile( + r"\b(?:" + r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|" + r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|" + r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|" + r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL" + r")\b", + re.IGNORECASE, +) +# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку). +# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE +# по-прежнему обязан прикрыть свой ALTER. +CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE) + +BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE) +SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE) +SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE) +NN_PREFIX = re.compile(r"^(\d+)") + + +def strip_noise(sql: str) -> str: + """Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на + пробелы (сохраняя длину и переводы строк — номера строк не съезжают). + + Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...' + легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу. + Тела $$...$$ (DO-блоки) НЕ затираются — там живёт исполняемый DDL. + """ + out: list[str] = [] + i, n = 0, len(sql) + while i < n: + ch = sql[i] + nxt = sql[i + 1] if i + 1 < n else "" + if ch == "-" and nxt == "-": + while i < n and sql[i] != "\n": + out.append(" ") + i += 1 + elif ch == "/" and nxt == "*": + depth = 1 # в PostgreSQL блочные комментарии вложенные + out.append(" ") + i += 2 + while i < n and depth: + if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*": + depth += 1 + out.append(" ") + i += 2 + elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/": + depth -= 1 + out.append(" ") + i += 2 + else: + out.append("\n" if sql[i] == "\n" else " ") + i += 1 + elif ch == "'": + out.append("'") + i += 1 + while i < n: + if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'": + out.append(" ") + i += 2 + continue + if sql[i] == "'": + break + out.append("\n" if sql[i] == "\n" else " ") + i += 1 + if i < n: + out.append("'") + i += 1 + else: + out.append(ch) + i += 1 + return "".join(out) + + +def scan(sql: str) -> list[str]: + """-> список претензий к файлу; пустой список = файл в порядке.""" + clean = strip_noise(sql) + statements = clean.split(";") + + first_blocking: int | None = None + blocking_text = "" + for idx, stmt in enumerate(statements): + if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt): + first_blocking = idx + blocking_text = " ".join(stmt.split())[:80] + break + if first_blocking is None: + return [] + + set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None) + if set_local is None: + if any(SET_BARE_LT.search(s) for s in statements): + return [ + f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). " + "Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY " + "в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN." + ] + return [ + f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой " + "строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет " + "в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)." + ] + + problems: list[str] = [] + if not any(BEGIN_STMT.search(s) for s in statements[:set_local]): + problems.append( + "`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). " + "Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)." + ) + if set_local > first_blocking: + problems.append( + f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — " + "к моменту DDL он ещё не действует. Подними его сразу под BEGIN." + ) + return problems + + +def selftest() -> None: + ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n" + assert scan(ok) == [], scan(ok) + + # красное: ровно случай 250 до фикса + bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n" + assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0] + assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n") + assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n") + assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n") + assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n") + + # красное: правильная строка, но в местах, где она не действует + assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0] + late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n" + assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late)) + bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + assert "без LOCAL" in scan(bare)[0] + + # зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC) + assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == [] + assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == [] + assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == [] + # ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL + mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле" + mixed_ok = ( + "CREATE INDEX CONCURRENTLY i ON t (c);\n" + "BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + ) + assert scan(mixed_ok) == [], scan(mixed_ok) + + # зелёное: DDL, которого нет — он в комментарии или в строковом литерале + assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == [] + assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == [] + assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == [] + assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == [] + assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == [] + # зелёное: не-DDL миграции (backfill/seed) правила не касаются + assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == [] + assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == [] + print("selftest OK") + + +def main() -> int: + if "--selftest" in sys.argv: + selftest() + return 0 + + failed = False + checked = 0 + for dirname, min_nn in SQL_DIRS.items(): + sql_dir = Path(dirname) + if not sql_dir.is_dir(): + print(f"::error::{sql_dir} не найден — запускать из корня репозитория") + return 1 + for path in sorted(sql_dir.glob("*.sql")): + m = NN_PREFIX.match(path.name) + if not m or int(m.group(1)) < min_nn: + continue # применено на проде до внедрения гейта — не переписываем + checked += 1 + for problem in scan(path.read_text(encoding="utf-8")): + failed = True + print(f"::error file={path}::{problem}") + if failed: + return 1 + print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-workflow-ports.py b/scripts/check-workflow-ports.py new file mode 100644 index 00000000..887c1169 --- /dev/null +++ b/scripts/check-workflow-ports.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757). + +ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host` +(проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}` +→ `host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём +`services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо +уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из +job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля. + +Список занятых портов — не догадки, а замер `ss -ltnp` на bot-server 2026-08-06. +Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ +СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они +живут в bridge-сетях) — сервис-контейнер с их портами ни с чем не столкнётся, +и запрещать их значило бы ронять сборку на ровном месте. + +Правильный способ дать сборке БД — как в ci-tradein.yml: явный `docker run` +в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера. + +Запуск: python3 scripts/check-workflow-ports.py [--selftest] +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06) +RESERVED: dict[int, str] = { + 22: "sshd хоста", + 53: "systemd-resolved (127.0.0.53)", + 80: "gendesign-caddy-1 (0.0.0.0:80)", + 443: "gendesign-caddy-1 (0.0.0.0:443)", + 2222: "forgejo, git-over-ssh (0.0.0.0:2222)", + 3000: "gendesign-frontend-1 (127.0.0.1:3000)", + 5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)", + 8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)", +} + +WORKFLOW_DIR = Path(".forgejo/workflows") + +# "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port) +PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?") +PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)") + + +def scan(text: str) -> list[tuple[int, str, int]]: + """-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p.""" + hits: list[tuple[int, str, int]] = [] + in_ports = False + ports_indent = 0 + + for lineno, raw in enumerate(text.splitlines(), 1): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue # комментарий ничего не запускает (в них живут анти-примеры) + indent = len(raw) - len(raw.lstrip()) + + if in_ports and (not stripped.startswith("- ") or indent <= ports_indent): + in_ports = False + + candidates: list[str] = [] + if stripped.startswith("ports:"): + rest = stripped[len("ports:") :].strip() + if rest: + candidates.append(rest) # инлайн-форма: ports: ["5432:5432"] + else: + in_ports, ports_indent = True, indent + elif in_ports: + candidates.append(stripped) + candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish + + for candidate in candidates: + for host_port, _container_port in PORT_PAIR.findall(candidate): + if int(host_port) in RESERVED: + hits.append((lineno, stripped, int(host_port))) + return hits + + +def selftest() -> None: + assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)] + assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000 + assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000 + assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443 + # безопасные формы и посторонние двоеточия — не должны ронять сборку + assert scan(" ports:\n - 5432\n") == [] + assert scan(" ports:\n - 15432:5432\n") == [] + assert scan(" image: postgis/postgis:16-3.4\n") == [] + assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == [] + assert scan(" # ports:\n # - 5432:5432\n") == [] + assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == [] + # выход из блока ports: по отступу + assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == [] + print("selftest OK") + + +def main() -> int: + if "--selftest" in sys.argv: + selftest() + return 0 + + if not WORKFLOW_DIR.is_dir(): + print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория") + return 1 + + failed = False + for path in sorted(WORKFLOW_DIR.glob("*.yml")): + for lineno, line, port in scan(path.read_text(encoding="utf-8")): + failed = True + print( + f"::error file={path},line={lineno}::публикация порта {port} — он занят " + f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер " + f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай " + f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP " + f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}" + ) + if failed: + return 1 + print("✓ ни один workflow не публикует занятый на VPS порт") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tradein-mvp/.gitignore b/tradein-mvp/.gitignore index 233693bc..a746885d 100644 --- a/tradein-mvp/.gitignore +++ b/tradein-mvp/.gitignore @@ -15,7 +15,10 @@ frontend/node_modules/ frontend/.next/ frontend/out/ frontend/dist/ -frontend/package-lock.json +# frontend/package-lock.json — TRACKED с #2770: Dockerfile и CI ставят `npm ci`, +# которому лок обязателен. Правишь frontend/package.json — регенерируй лок +# (`npm install --legacy-peer-deps --no-audit --no-fund`) в том же PR, иначе +# `npm ci` роняет сборку образа и весь deploy откатывается. frontend/next-env.d.ts # Docker diff --git a/tradein-mvp/CHANGELOG.md b/tradein-mvp/CHANGELOG.md new file mode 100644 index 00000000..0e70377d --- /dev/null +++ b/tradein-mvp/CHANGELOG.md @@ -0,0 +1,46 @@ +# История версий «МЕРА» + +Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и +[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно +`## ` (машинно читается страницей истории версий). + +## 2.1.0 — 2026-08-10 + +Первая версия с явным версионированием. Номер продолжает ряд, который до этого +показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние +отчёты. + +### Добавлено + +- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и + реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень + уверенности в оценке. +- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав + аналогов и сделок, формирование выкупной стоимости. +- Аналитика по дому — история размещений объявлений и продаж в доме. +- История прошлых оценок в личном кабинете, автодополнение адреса при поиске. +- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история). +- Чат поддержки на сайте, в том числе без входа в личный кабинет. +- Публичный лендинг «МЕРА». +- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта + страница истории версий. + +### Изменено + +- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более + раннего технического макета. + +### Исправлено + +- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе + комнатности не было варианта «Студия», из-за чего для студии подбирались + однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась. +- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор + автоматически расширяется (студии, срок объявлений, новостройки, радиус), + а над результатом показывается предупреждение о сниженной точности и о том, + какие параметры пришлось расширить. +- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части + адресов улица не распознавалась, и разделы оставались пустыми. +- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов. +- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки + на обложке, некорректные допущения о сроке экспозиции. diff --git a/tradein-mvp/VERSION b/tradein-mvp/VERSION new file mode 100644 index 00000000..7ec1d6db --- /dev/null +++ b/tradein-mvp/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile index 4958161c..24d65162 100644 --- a/tradein-mvp/backend/Dockerfile +++ b/tradein-mvp/backend/Dockerfile @@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages COPY --from=builder --chown=app:app /app/backend/app /app/app COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts +# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего +# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION). +# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста. +COPY --chown=app:app VERSION VERSION + +# Версия продукта + короткий git SHA + дата сборки — запечены как build-args +# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend). +# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам +# (VERSION-файл выше / "dev" / момент импорта модуля). +# +# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых +# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на +# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои +# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале +# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get + +# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот +# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже). +ARG APP_VERSION="" +ARG BUILD_SHA="" +ARG BUILD_DATE="" +ENV APP_VERSION=$APP_VERSION \ + BUILD_SHA=$BUILD_SHA \ + BUILD_DATE=$BUILD_DATE + USER app # HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 4a803c64..9eab49fe 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -72,7 +72,9 @@ from app.services import cian_session as cian_session_svc from app.services import domclick_session as domclick_session_svc from app.services import proxy_rotation as proxy_rotation_svc from app.services import scrape_runs as runs_mod +from app.services.estimator import LISTINGS_FRESH_DAYS from app.services.geocoder import geocode, known_city_hint +from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url from app.services.proxy_pool import clear_source_bans from app.services.scheduler import has_running_run from app.services.scraper_adapters import ( @@ -231,6 +233,8 @@ async def scrape_around( ) else: lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m) + # run_id нет и не будет (#2701): ручной admin-скрейп строки в scrape_runs не + # заводит — снимок пишется вне прогона, поле честно остаётся NULL. inserted, updated = save_listings( db, lots, matcher=matcher, region_code=DEFAULT_REGION_CODE ) @@ -370,6 +374,56 @@ async def geocode_missing( } +def _cian_verify_state_error(state: dict[str, Any] | None) -> HTTPException | None: + """Маппинг исхода cian_session_svc.verify_session() на HTTP-ответ админки. + + verify_session() возвращает 4 разных исхода (см. докстринг сервиса) плюс успех — + их нельзя схлопывать в один "cookies invalid", иначе бан по IP выглядит так же, + как протухшие куки, и человек в момент инцидента перезаливает заведомо валидные + куки вместо починки egress/прокси (инцидент 2026-08-10). + + Sentinel'ы сравниваются через `is`, НЕ `==` — так требует докстринг verify_session. + + Возвращает None, если state — это успешно распаршенный state dict (в т.ч. случай + "успех, но userId не найден" — этот случай caller должен обработать отдельно). + """ + if state is cian_session_svc.VERIFY_BAN_SENTINEL: + return HTTPException( + status_code=503, + detail=( + "Cian заблокировал наш IP (HTTP 403, TLS/bot-fingerprint ban). " + "Куки, скорее всего, валидны — блокировка не про них. " + "Нужно чинить egress: проверить SCRAPER_PROXY_URL и баны в " + "scrape_proxy_source_bans. Перезаливать куки бесполезно. " + "(CIAN_PROXY_URL — мёртвая переменная, снята в #2616.)" + ), + ) + if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL: + return HTTPException( + status_code=503, + detail=( + "Cian временно недоступен (5xx или сетевой сбой при проверке кук). " + "Повторите проверку позже. Куки не трогать — источник просто не ответил." + ), + ) + if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL: + return HTTPException( + status_code=500, + detail=( + "Cian изменил вёрстку/схему страницы — auth-state не найден/не " + "распарсился (scraper_kit.cian_state_parser.extract_state, MFE " + "header-frontend). Нужен инженерный фикс парсера, перезалив кук " + "проблему НЕ решит." + ), + ) + if state is None: + return HTTPException( + status_code=401, + detail="Куки протухли или сессия разлогинена на cian.ru — перезалейте куки.", + ) + return None + + @router.post("/scrape/cian/upload-cookies", status_code=200) async def upload_cian_cookies( cookies: dict[str, str], @@ -402,15 +456,21 @@ async def upload_cian_cookies( ) state = await cian_session_svc.verify_session(cleaned) - if state is None: - raise HTTPException( - status_code=401, - detail="Cookies invalid or session not authenticated on cian.ru", - ) + verify_error = _cian_verify_state_error(state) + if verify_error is not None: + raise verify_error + assert state is not None # narrowed by _cian_verify_state_error above user_id = state.get("user", {}).get("userId") if not user_id: - raise HTTPException(status_code=400, detail="Authenticated state missing userId") + raise HTTPException( + status_code=400, + detail=( + "Cian подтвердил аутентификацию (state распарсился), но userId в " + "ответе не найден — структура state неожиданная, куки тут ни при " + "чём, смотрите server logs." + ), + ) cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned) return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)} @@ -477,15 +537,21 @@ async def cian_auto_login( ) state = await cian_session_svc.verify_session(cleaned) - if state is None: - raise HTTPException( - status_code=401, - detail="Logged in but session not authenticated (cookies rejected by cian.ru)", - ) + verify_error = _cian_verify_state_error(state) + if verify_error is not None: + raise verify_error + assert state is not None # narrowed by _cian_verify_state_error above user_id = state.get("user", {}).get("userId") if not user_id: - raise HTTPException(status_code=400, detail="Authenticated state missing userId") + raise HTTPException( + status_code=400, + detail=( + "Cian подтвердил аутентификацию (state распарсился), но userId в " + "ответе не найден — структура state неожиданная, куки тут ни при " + "чём, смотрите server logs." + ), + ) cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned) return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)} @@ -497,6 +563,12 @@ async def test_cian_auth( ) -> dict: """Проверить что текущие сохранённые Cian cookies ещё валидны. + reason различает 5 исходов (см. cian_session_svc.verify_session докстринг): + "banned_403" — куки, вероятно, ОК, блокирован IP; "source_unavailable" — + Cian недоступен, куки ни при чём; "markup_changed" — вёрстка Cian сменилась, + нужен фикс парсера; "session_expired_or_invalid" — куки реально протухли; + "no_session_in_db" / "encryption_key_not_configured" — конфигурация/данных нет. + Returns: {"authenticated": bool, "userId": , "reason": } """ if not settings.cookie_encryption_key: @@ -507,10 +579,14 @@ async def test_cian_auth( return {"authenticated": False, "userId": None, "reason": "no_session_in_db"} state = await cian_session_svc.verify_session(cookies) + if state is cian_session_svc.VERIFY_BAN_SENTINEL: + return {"authenticated": False, "userId": None, "reason": "banned_403"} + if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL: + return {"authenticated": False, "userId": None, "reason": "source_unavailable"} + if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL: + return {"authenticated": False, "userId": None, "reason": "markup_changed"} if state is None: return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"} - if state.get("_ban"): - return {"authenticated": False, "userId": None, "reason": "banned_403"} user_id = state.get("user", {}).get("userId") return {"authenticated": True, "userId": user_id, "reason": None} @@ -1591,8 +1667,22 @@ def update_schedule( """UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT).""" from app.services.scheduler import compute_next_run_at - # Compute new next_run_at если window изменился — recompute, иначе keep existing - next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour) + # #2674: такт берётся из default_params — ровно как его читает планировщик + # (_claim_run/_defer_next_run_at). Без него compute_next_run_at падал на default=1 и + # ЛЮБОЕ сохранение сбивало источник на «завтра»: недельный avito_full_load после + # правки окна побежал бы через сутки. На суточных источниках баг был невидим — + # для них «завтра» и есть правильный ответ. + # None-safe так же, как в scheduler: `"interval_days": null` в jsonb → 1, не TypeError. + _interval_days = payload.default_params.get("interval_days") + interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1 + + # Явно заданный оператором момент уважается как есть (в т.ч. в прошлом — «запустить + # сейчас»). Иначе считаем от такта. + next_at = payload.next_run_at or compute_next_run_at( + payload.window_start_hour, + payload.window_end_hour, + interval_days=interval_days, + ) row = ( db.execute( @@ -1606,7 +1696,22 @@ def update_schedule( window_start_hour = EXCLUDED.window_start_hour, window_end_hour = EXCLUDED.window_end_hour, default_params = EXCLUDED.default_params, - next_run_at = EXCLUDED.next_run_at, + -- #2674: не двигаем уже назначенный запуск, если двигать не за чем. + -- Раньше next_run_at перезаписывался ВСЕГДА, поэтому правка соседнего + -- поля (enabled, request_delay_sec в params) заново разыгрывала момент + -- внутри окна и сдвигала прогон. Сохраняем существующий только когда он + -- ещё в будущем И ни окно, ни такт не менялись — тогда пересчёт дал бы + -- то же самое окно, только с другим random-смещением. + next_run_at = CASE + WHEN CAST(:explicit AS boolean) THEN EXCLUDED.next_run_at + WHEN scrape_schedules.next_run_at > NOW() + AND scrape_schedules.window_start_hour = EXCLUDED.window_start_hour + AND scrape_schedules.window_end_hour = EXCLUDED.window_end_hour + AND COALESCE(scrape_schedules.default_params ->> 'interval_days', '1') + = COALESCE(EXCLUDED.default_params ->> 'interval_days', '1') + THEN scrape_schedules.next_run_at + ELSE EXCLUDED.next_run_at + END, updated_at = NOW() RETURNING id, source, enabled, window_start_hour, window_end_hour, default_params, last_run_id, last_run_at, next_run_at, updated_at @@ -1619,6 +1724,7 @@ def update_schedule( "we": payload.window_end_hour, "params": json.dumps(payload.default_params, ensure_ascii=False), "next_at": next_at, + "explicit": payload.next_run_at is not None, }, ) .mappings() @@ -1871,9 +1977,16 @@ async def scrape_cian_detail( Without it → debug-only (no DB write). """ _assert_allowed_url(offer_url) + from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment - enrichment = await fetch_detail(offer_url, config=RealScraperConfig()) + try: + enrichment = await fetch_detail(offer_url, config=RealScraperConfig()) + except CianBlockedError as exc: + # #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке + # нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с + # этого узла» — разные новости для того, кто дёргает ручку руками. + raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc if enrichment is None: raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}") @@ -1920,14 +2033,17 @@ async def scrape_cian_newbuilding( save_newbuilding_enrichment, ) - enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig()) + enrichment = await fetch_newbuilding( + zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider() + ) if enrichment is None: raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}") saved = False if house_id is not None: - # save_newbuilding_enrichment — sync (def, returns None); await на sync-функции - # раньше поднимал TypeError на любом вызове с house_id. + # save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции + # раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт + # записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения. save_newbuilding_enrichment(db, house_id, enrichment) saved = True @@ -2188,12 +2304,30 @@ async def scrape_house_imv_backfill( class UnifiedScrapeRunRow(BaseModel): - """Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).""" + """Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче). + + #2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом + 'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и + таблица подписывала им прогоны, которые никаким sweep не были — + proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало, + называет `source`. + """ run_id: int source: str - run_type: str | None = None status: str + # #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была + # пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор + # видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену + # не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а + # has_running_run перестал бы держать single-run guard. + cancellable: bool = False + # #2686: диагноз для status='banned' — 'platform' (площадка заблокировала), + # 'infra' (не отдал наш браузерный сайдкар) или 'unknown' (#2764 — причина не + # установлена; раньше такие прогоны молча получали 'platform'). Без него + # оператор видит только «забанен» и делает вывод «площадка нас палит» на 80% + # наших же отказов. + ban_kind: str | None = None params: dict | None = None counters: dict | None = None total_seen: int | None = None @@ -2209,6 +2343,12 @@ class UnifiedScrapeRunsResponse(BaseModel): rows: list[UnifiedScrapeRunRow] +class ScrapeRunSourcesResponse(BaseModel): + """Список source'ов для фильтра истории прогонов — из данных, не из литерала.""" + + sources: list[str] + + class BrowserHealth(BaseModel): reachable: bool browsers: dict[str, bool] = Field(default_factory=dict) @@ -2231,18 +2371,33 @@ class ScraperHealthResponse(BaseModel): _ROTATABLE_SOURCES = ("avito", "cian", "yandex") -def _provider_proxy_url(source: str) -> str | None: - """Effective proxy URL для source (учитывает property-fallback в settings). +def _provider_proxy_url(db: Session, source: str) -> str | None: + """Узел, который РЕАЛЬНО получит трафик этого источника (#2830). - #2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url - (per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая - mobileproxy-подписка, #2613). + Раньше здесь стоял `settings.scraper_proxy_url` — одна и та же статичная + переменная для всех трёх источников. После #2825/#2831 egress выбирается из + `scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть + страница показывала один узел, а трафик шёл через другой — слепое пятно ровно + того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел, + забаненный и Avito, и Cian), только теперь на диагностической странице. + + Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`, + `*_detail_backfill`) — не «похожая логика», иначе страница снова начнёт + расходиться с трафиком. + + Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only, + lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не + его репутацию у Авито/Циана — присваивать узлу отказ по чужой пробе значит + выдавать ему чужой бан. """ - return { - "avito": settings.scraper_proxy_url, - "cian": settings.cian_proxy_url, - "yandex": settings.yandex_proxy_url, - }.get(source) + try: + return resolve_proxy_url(db, source) + except ProxyPoolExhaustedError: + # Пул не пуст, но для source не осталось ни одного здорового небаненного узла. + # resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть + # страница покажет «—», а не статичный env-узел (зелёная строка на месте + # отказа хуже пустой). + return None def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]: @@ -2262,7 +2417,11 @@ def list_scrape_runs_unified( db: Annotated[Session, Depends(get_db)], source: Annotated[str | None, Query()] = None, status: Annotated[ - Literal["done", "running", "banned", "zombie", "failed", "cancelled"] | None, Query() + # 'skipped' (#2658) — пропущенное расписание; без него оператор не может + # спросить «что сейчас пропускается» (фильтр отдавал 422 на единственной + # поверхности, построенной ровно для этого вопроса). + Literal["done", "running", "banned", "zombie", "failed", "cancelled", "skipped"] | None, + Query(), ] = None, limit: Annotated[int, Query(ge=1, le=200)] = 50, offset: Annotated[int, Query(ge=0)] = 0, @@ -2274,7 +2433,7 @@ def list_scrape_runs_unified( Query: source — опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...). - status — опц. фильтр (done/running/banned/zombie/failed/cancelled). + status — опц. фильтр (done/running/banned/zombie/failed/cancelled/skipped). limit — default 50, max 200. offset — default 0. """ @@ -2289,8 +2448,9 @@ def list_scrape_runs_unified( UnifiedScrapeRunRow( run_id=r["run_id"], source=r["source"], - run_type=r.get("run_type"), status=r["status"], + cancellable=runs_mod.honors_cancel(str(r["source"])), + ban_kind=r.get("ban_kind"), params=r.get("params"), counters=r.get("counters"), total_seen=r.get("total_seen"), @@ -2305,6 +2465,21 @@ def list_scrape_runs_unified( ) +@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse) +def list_scrape_run_sources( + db: Annotated[Session, Depends(get_db)], +) -> ScrapeRunSourcesResponse: + """Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs. + + #2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в + таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением — каждый + пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том + числе) были недоступны для вопроса «что там происходит». Список берётся из + данных: новый source появляется в фильтре сам, без правки кода. + """ + return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db)) + + async def _probe_browser_health() -> BrowserHealth: """GET tradein-browser /health (timeout 5с). reachable=False при ошибке.""" url = f"{settings.browser_http_endpoint.rstrip('/')}/health" @@ -2338,19 +2513,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None: @router.get("/scraper/health", response_model=ScraperHealthResponse) -async def scraper_health() -> ScraperHealthResponse: +async def scraper_health( + db: Annotated[Session, Depends(get_db)], +) -> ScraperHealthResponse: """Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры. - fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser). - browser: GET tradein-browser /health (reachable + per-browser ready-флаги). - - providers: для avito/cian/yandex — proxy host/port, rotate_supported - (#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый - аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611, - не per-provider-source), best-effort current_ip (пробинг через прокси на ipify). + - providers: для avito/cian/yandex — узел, который пул отдаст ЭТОМУ источнику + сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного + здорового узла), rotate_supported (#2616 шаг 2: всегда False — changeip + mobileproxy-ротация снята, мёртвый аккаунт #2613; живая ASocks-ротация — + POST /admin/proxies/{id}/rotate, #2611, не per-provider-source), best-effort + current_ip (пробинг через этот же узел на ipify). Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с. """ - proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES} + proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES} browser, *ips = await asyncio.gather( _probe_browser_health(), @@ -2473,6 +2652,12 @@ async def update_scraper_pacing( class SourceCoverage(BaseModel): source: str active_count: int + # #2660: «активно» ≠ «живо». is_active снимается только деактиватором протухших, + # а он покрывает не все источники — на проде (2026-08-05) cian показывал 18 530 + # активных при 12 683 не виденных 14+ дней. Из-за этого #2574 месяц читалась как + # «всё собирается». Не прячем протухшее из счётчика, а отдаём ВТОРЫМ числом + # рядом — тогда «активно» перестаёт читаться как «живо». + stale_count: int fields: dict[str, float] # field_name -> fill% (0..100, round 1) @@ -2487,6 +2672,9 @@ class HousesCoverage(BaseModel): class DataQualityResponse(BaseModel): sources: list[SourceCoverage] houses: HousesCoverage + # Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI + # подписывал число, а не хардкодил порог у себя вторым определением. + stale_days: int # Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL). @@ -2499,7 +2687,8 @@ _DQ_LISTING_FIELDS: list[tuple[str, str]] = [ ("lon", "lon IS NOT NULL"), ("kitchen_area_m2", "kitchen_area_m2 IS NOT NULL"), ("living_area_m2", "living_area_m2 IS NOT NULL"), - ("ceiling_height", "ceiling_height IS NOT NULL"), + # #2699: одна колонка вместо двух. ceiling_height (019) DEPRECATED — писатели + # переведены на ceiling_height_m, исторические значения перенесены (мигр. 238). ("ceiling_height_m", "ceiling_height_m IS NOT NULL"), ("metro_stations", "metro_stations IS NOT NULL AND metro_stations <> '[]'::jsonb"), ] @@ -2513,9 +2702,13 @@ def get_data_quality( Один проход per source через COUNT(*)...FILTER — не N запросов. Поля listings: description, photo_urls, address, lat/lon, kitchen_area_m2, - living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations. + living_area_m2, ceiling_height_m (все источники, #2699), metro_stations. houses: total, avito_validated_at%, rating_score%, house_type%. house_reviews: общий count. + + #2660: рядом с active_count отдаётся stale_count — сколько из «активных» не + виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе + (stale_days), чтобы UI не заводил второе определение. """ # Строим single-pass SELECT для listings полей через FILTER-агрегаты. # Структура: COUNT(*) FILTER (WHERE ) / NULLIF(COUNT(*), 0) * 100 @@ -2523,10 +2716,17 @@ def get_data_quality( filter_exprs = ", ".join( f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS ) + # last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не + # виделись». На проде две колонки не расходятся (замер 2026-08-05: 0 активных + # строк с разницей ≥ суток), но семантика счётчика — про «видели», и колонка + # должна называть ровно её. sql_listings = text(f""" SELECT source, COUNT(*) AS active_count, + COUNT(*) FILTER ( + WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval + ) AS stale_count, {filter_exprs} FROM listings WHERE is_active = true @@ -2534,7 +2734,7 @@ def get_data_quality( ORDER BY source """) - rows = db.execute(sql_listings).mappings().all() + rows = db.execute(sql_listings, {"fresh_days": LISTINGS_FRESH_DAYS}).mappings().all() sources: list[SourceCoverage] = [] for row in rows: @@ -2548,6 +2748,7 @@ def get_data_quality( SourceCoverage( source=row["source"], active_count=int(row["active_count"]), + stale_count=int(row["stale_count"] or 0), fields=fields, ) ) @@ -2575,7 +2776,7 @@ def get_data_quality( reviews_count=reviews_count, ) - return DataQualityResponse(sources=sources, houses=houses) + return DataQualityResponse(sources=sources, houses=houses, stale_days=LISTINGS_FRESH_DAYS) # ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ─────────────────── diff --git a/tradein-mvp/backend/app/api/v1/audit.py b/tradein-mvp/backend/app/api/v1/audit.py index e592d5b1..feac11aa 100644 --- a/tradein-mvp/backend/app/api/v1/audit.py +++ b/tradein-mvp/backend/app/api/v1/audit.py @@ -58,6 +58,12 @@ async def list_accounts( count(*) FILTER (WHERE event_type = 'api_request') AS request_count, count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count FROM user_events + -- Событие без имени — не аккаунт (#2715: `login_verify_saturated` + -- пишется с пустым именем намеренно — отказ случается ДО того, как + -- на имя посмотрели). Без фильтра строка встала бы ПЕРВОЙ (её + -- last_seen_at — момент атаки), а её кнопка в UI раскрывалась бы в + -- /audit/accounts/{username} с `min_length=1`, то есть в ошибку. + WHERE username <> '' GROUP BY username ORDER BY last_seen_at DESC """ @@ -182,12 +188,16 @@ async def analytics_dashboard( db.execute( text( """ + -- NULLIF(username, ''): безымянные события (#2715) — СОБЫТИЯ, они + -- честно входят в total_events, но не люди: count(DISTINCT) их + -- игнорирует по NULL, иначе первая же атака навсегда добавила бы + -- фантомного пользователя в счётчик уникальных. SELECT count(*) AS total_events, - count(DISTINCT username) AS distinct_users, + count(DISTINCT NULLIF(username, '')) AS distinct_users, count(*) FILTER ( WHERE created_at >= now() - INTERVAL '24 hours' ) AS events_last_24h, - count(DISTINCT username) FILTER ( + count(DISTINCT NULLIF(username, '')) FILTER ( WHERE created_at >= now() - INTERVAL '24 hours' ) AS active_users_last_24h FROM user_events @@ -204,7 +214,7 @@ async def analytics_dashboard( """ SELECT date_trunc('day', created_at)::date AS day, count(*) AS events, - count(DISTINCT username) AS users + count(DISTINCT NULLIF(username, '')) AS users -- см. выше (#2715) FROM user_events WHERE created_at >= now() - make_interval(days => CAST(:days AS int)) GROUP BY date_trunc('day', created_at)::date @@ -261,6 +271,7 @@ async def analytics_dashboard( count(*) FILTER (WHERE event_type = 'estimate_request') AS searches, max(created_at) AS last_seen FROM user_events + WHERE username <> '' -- не аккаунт, см. /audit/accounts выше (#2715) GROUP BY username ORDER BY events DESC LIMIT 50 diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index a839d936..af346cde 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -28,22 +28,50 @@ Security: username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip) парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель в username недостаточно — паразитная граница возможна с обеих сторон). + - Настоящий ПОТОЛОК ТЕМПА — `verify_password_bounded` (#2665): bcrypt считает + 282 мс, и ровно столько же он раньше держал заблокированным единственный + событийный цикл, кладя вместе с логином ВЕСЬ API. Теперь bcrypt крутится в + пуле из `login_password_verify_workers` потоков, а число потоков и есть + потолок (проверок/с не больше workers/282мс). Убрать одно без другого + нельзя: вынос без потолка ускорил бы перебор вчетверо, потолок без выноса + оставил бы отказ в обслуживании. Сверх очереди — 429, не ожидание. + Слоты делятся ПО АДРЕСУ (#2714): один источник не занимает больше половины, + иначе потолок бил и по своим — легитимный вход с верным паролем во время + флуда получал 429 столько раз, сколько пытался. Ключ — IP, поэтому защита + поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси, + общий адрес за NAT, ротация через ботнет) — см. docstring той же функции. + Отказ по насыщению выдаётся ДО выборки из реестра (#2715): иначе на этом + пути оставалась бы единственная работа, время которой зависит от того, + существует ли имя, — а bcrypt, который эту разницу ровняет, до него уже не + доходит. След инцидента — агрегированный, `_saturated_429`. + - Поверх него — ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571): + лимит по паре (username, IP) распределённый перебор обходит целиком, просто + меняя адрес. Превышение порога не блокирует вход, а замедляет ответ + (`_throttle_delay_s`) — см. развёрнутое обоснование там же. - Raw-пароль НИКОГДА не логируется и не попадает в user_events payload — - только username/ip/user_agent/path/method (см. schedule_event ниже). + только username/ip/user_agent/path/method и (для неудач) состояние + счётчика попыток: сколько их за окно и какая задержка применена. """ from __future__ import annotations +import asyncio import logging import secrets +import time from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.config import settings -from app.core.password import hash_password, verify_password +from app.core.password import ( + PasswordVerifyOverloadedError, + hash_password, + verify_password_bounded, + verify_slots_saturated, +) from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.services.auth_session import create_session, get_user_by_username, revoke_session from app.services.identity_store import AccessState, get_identity_db @@ -63,6 +91,32 @@ _LOGIN_LIMITER = SlidingWindowLimiter( window_s=settings.login_rate_limit_window_s, ) +# Глобальный счётчик неудач НА ИМЯ (#2571) — ключ БЕЗ IP, поэтому попытки со +# всех адресов складываются в один бюджет. Дополняет `_LOGIN_LIMITER`, а не +# заменяет: тот режет частый перебор с одного адреса, этот — редкий, но с +# тысячи адресов (credential stuffing), от которого per-(username, IP) ключ не +# защищает вообще — каждый новый адрес получает свежие login_rate_limit попыток. +# +# Живёт В ПАМЯТИ ПРОЦЕССА — сознательно, а не по недосмотру. Прод-бэкенд +# запущен одним uvicorn-воркером (docker-compose.prod.yml, комментарий над +# `command`: «Single worker сохраняется для предсказуемости»), значит счётчик и +# так глобален, а Redis в auth-пути добавил бы сетевую зависимость там, где её +# падение = либо дыра (fail-open), либо отказ входа (fail-closed). +# Потолок: появятся воркеры (`--workers N`) — потолок делится на N, и его надо +# переносить в Redis (`app.services.cache` уже держит там пул). Тот же ceiling +# у соседнего `_LOGIN_LIMITER`; перезапуск процесса обнуляет оба. +# +# ⚠️ `limit` здесь НЕ ПОРОГ и ничего не режет: мы зовём только `record()`, а он +# на лимит не смотрит — считает и отдаёт число попыток в окне. Настоящий порог +# живёт в `_throttle_delay_s`, которая читает настройку на каждом вызове (и +# потому подхватывает monkeypatch в тестах). Значение продублировано сюда ровно +# для того, чтобы `retry_after()` на этом объекте — если его однажды позовут — +# отвечал по тому же числу, а не по случайному. +_USERNAME_FAIL_LIMITER = SlidingWindowLimiter( + limit=settings.login_username_fail_threshold, + window_s=settings.login_username_fail_window_s, +) + # Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля, # сгенерированный ОДИН РАЗ на импорте модуля — используется вместо # password_hash, когда юзер не найден/деактивирован/без пароля, чтобы @@ -84,7 +138,15 @@ _ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился" class LoginRequest(BaseModel): - username: str + # max_length=64 — ровно верхняя граница CHECK'а реестра + # (`users_username_ascii_ck`, data/sql/auth/001), так что живое имя отсечь + # нельзя. Ограничение нужно не валидации ради: сырое имя становится ключом + # ОБОИХ лимитеров, а их `defaultdict` подчищается только при >10000 ключей и + # только от пустых корзин — при окне в час корзины непустые, освобождать + # нечего. Без границы длины килобайтные имена растили бы память ключами. + # Паттерн/минимум длины НЕ дублируем: в режиме `identity_store="tradein"` + # CHECK'а нет и живут не-ASCII имена (см. тест на кириллицу). + username: str = Field(max_length=64) password: str @@ -92,6 +154,208 @@ class LoginResponse(BaseModel): ok: bool = True +def _throttle_delay_s(fails_in_window: int) -> float: + """Насколько задержать ответ на неудачный вход при *fails_in_window* неудачах + по этому имени за окно. 0 — пока порог не перебран. + + Замедление, а НЕ блокировка — намеренно. Жёсткая блокировка учётки после N + неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он + гарантированно выключает вход конкретному человеку (директору, админу) — + отказ в обслуживании дешевле и надёжнее, чем то, от чего блокировка + защищает. Задержка же не отнимает доступ ни у кого: владелец пароля войдёт + с первой попытки, просто ответ на очередную НЕУДАЧУ придёт медленнее. + + Рост удвоением от 1с с потолком `login_username_throttle_max_delay_s`: + первые перебранные попытки почти незаметны, а сотни — упираются в потолок. + Потолок обязателен: без него задержка становится той же блокировкой, только + растянутой во времени. + + Показатель степени зажат (`min(..., 16)`) — это не косметика. `min()` считает + ОБА аргумента до сравнения, поэтому наивный `float(2 ** (excess - 1))` при + excess>=1025 падает с `OverflowError: int too large to convert to float` — + то есть ровно под целевой нагрузкой (1045 неудач по имени за час = 0.3 rps) + защита начинала отдавать 500 мгновенно и без аудита, вместо 401 с задержкой. + 2**16 = 65536с заведомо больше любого разумного потолка, так что зажим + видимого поведения не меняет, а арифметику делает безусловно конечной. + """ + excess = fails_in_window - settings.login_username_fail_threshold + if excess <= 0: + return 0.0 + return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16)) + + +# Не чаще одной записи в это окно на ВСЕ отказы по насыщению (#2715). Окно, а не +# запись на запрос, потому что лог у бэкенда общий и ограниченный (docker +# json-file, max-size 20m × max-file 3): при флуде в сотни запросов в секунду +# строка на каждый отказ прокручивает 60 МБ за минуты и выселяет ВСЕ остальные +# логи ровно во время атаки — то есть в момент, когда они нужнее всего. +# Значение не в настройках намеренно: это не тюнинг, а «человек читает лог», и +# крутить его нечем — меньше секунды возвращает исходную проблему, больше +# ухудшает разрешение по времени, не давая взамен ничего. +_SATURATION_REPORT_WINDOW_S = 1.0 + +# Отказов с прошлой записи и когда была прошлая запись (monotonic; None — записи +# ещё не было). Обычные глобалы без лока — по той же причине, что и счётчик +# слотов в `app.core.password`: обе строчки исполняются в потоке событийного +# цикла и между чтением и записью нет `await`. +_saturation_rejected = 0 +_saturation_reported_at: float | None = None + + +def _saturated_429(ip: str) -> HTTPException: + """429 «слоты сверки заняты» + АГРЕГИРОВАННЫЙ след инцидента. + + Событие неудачного входа тут не пишется и бюджет неудач по имени не + тратится сознательно (#2712): пароль не проверялся, это не попытка входа, а + трата бюджета означала бы, что насыщением можно заблокировать чужую учётку. + Но тогда весь инцидент виден ровно здесь, и до #2715 — только строкой в + логе на каждый отклонённый запрос (см. `_SATURATION_REPORT_WINDOW_S`). + + Поэтому на окно приходится одна строка в лог И одно событие + `login_verify_saturated` в `user_events` — с числом отказов, накопленных с + прошлой записи. Событие важнее строки: аудит переживает и ротацию логов, и + редеплой. Первый отказ отчитывается сразу, а не в конце окна: одиночная + аномалия обязана быть видна, даже если продолжения не будет. + + `since_prev_s` в payload — НЕ дубль `created_at`, а единственный способ + прочитать счётчик правильно. Хвост копится, пока не придёт следующий отказ: + атака кончилась в 03:00, 900 отказов остались неотчитанными — и во вторник + одиночный 429 соседа по NAT унёс бы их все в запись, датированную вторником + и подписанную АДРЕСОМ СОСЕДА. С `since_prev_s` видно, что 901 отказ + накоплен за неделю, а не за секунду, и что читать `ip` в этой записи не + надо. `None` — первая запись за жизнь процесса, сравнивать не с чем. + + Уровень ERROR, а не WARNING, — не косметика: бэкенд поднят с + `LoggingIntegration(level=INFO, event_level=ERROR)` (app/main.py), то есть + ровно с ERROR запись становится событием GlitchTip, а WARNING остаётся + строкой в docker-логе, которая умирает с ротацией и редеплоем. Цена + прецедента известна (#2674): монитор писал WARNING про протухшие куки — и + событий было ноль. Спама не будет: запись не чаще раза в окно, и все они + группируются в один issue (шаблон сообщения один). + + Чего это НЕ делает: у GlitchTip-проекта нет ни правил, ни получателей + (#2673), так что уведомление никому не уйдёт — событие будет видно в + интерфейсе, но не в чьём-то телефоне. Проверить доставку поведенчески + сейчас не на чем, и утверждать её здесь было бы враньём. + + `username=""` — не заглушка: имя не пишем ПОТОМУ, что отказ случился до + того, как мы на него посмотрели. Записывай мы присланное, атакующий + наполнял бы аудит строками с любым именем на выбор. Пустое имя — не аккаунт, + и списки аудита его отфильтровывают (`WHERE username <> ''` в + `app/api/v1/audit.py`), иначе оно встало бы первой строкой в списке + аккаунтов и фантомом в `count(DISTINCT username)`. `ip` — адрес последнего + отклонённого запроса, то есть ОБРАЗЕЦ: при распределённом флуде адресов + много, и по одной записи их не восстановить (счётчик — восстановит). + + Потолок объёма: час непрерывной атаки — это 3600 строк в `user_events` + (в таблице за всю её жизнь ~3.4 тысячи), сутки — под 86 тысяч. Retention у + таблицы нет, а `GET /audit/accounts` делает полный `GROUP BY` без фильтра по + времени. То же давление уходит на квоту проекта в GlitchTip — тот же + механизм вытеснения чужого сигнала, только в другом ведре. Дойдёт до этого — + окно агрегации растёт с длительностью атаки (экспонента с потолком, как у + `_throttle_delay_s`), это следующий шаг, а не сегодняшний. + """ + global _saturation_rejected, _saturation_reported_at + + _saturation_rejected += 1 + now = time.monotonic() + since_prev = None if _saturation_reported_at is None else now - _saturation_reported_at + if since_prev is None or since_prev >= _SATURATION_REPORT_WINDOW_S: + rejected, _saturation_rejected = _saturation_rejected, 0 + _saturation_reported_at = now + logger.error( + "login rejected: password verify saturated — %d отказов, " + "с прошлой записи %s с, последний ip=%s", + rejected, + "—" if since_prev is None else f"{since_prev:.1f}", + ip, + ) + schedule_event( + event_type="login_verify_saturated", + username="", + ip=ip, + path="/api/v1/auth/login", + method="POST", + payload={ + "rejected": rejected, + # Считается ДО сдвига `_saturation_reported_at` — иначе всегда 0. + "since_prev_s": None if since_prev is None else round(since_prev, 1), + }, + ) + + # Retry-After 1с — порядок времени одной сверки, не окно соседнего + # `_LOGIN_LIMITER`. Ответ ОДИН И ТОТ ЖЕ для любого имени: отказ приходит до + # сверки и потому ничего не сообщает о том, существует ли учётка. + return HTTPException( + status_code=429, + detail="слишком много попыток входа, попробуйте позже", + headers={"Retry-After": "1"}, + ) + + +async def _reject_invalid_credentials( + db: Session, username: str, ip: str, user_agent: str | None +) -> HTTPException: + """Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 401. + + Один код на все ветки отказа (нет такого имени / неверный пароль / доступ + закрыт / password_hash NULL) — это не борьба с дублированием, а инвариант: + ветки обязаны быть неразличимы снаружи. Разъедься они по телу хендлера — + и достаточно забыть задержку в одной, чтобы «быстрый 401» стал оракулом + существования учётки ровно в том же виде, что и разные сообщения об ошибке. + Поэтому счётчик ведётся по ПРИСЛАННОМУ имени, без проверки, есть ли такое + в реестре: несуществующее имя копит неудачи и тормозит так же, как живое. + (`get_user_by_username` сверяет `username = :username` по text-колонке без + нормализации, так что сырое имя — тот же ключ, что и у поиска: регистром + счётчик не обойти.) + + Возвращает `HTTPException`, а не бросает: `raise await …` не собирается, а + `raise (await …)` читается хуже, чем `raise` над возвращённым значением. + + *db* нужен ровно затем, чтобы ОТДАТЬ соединение перед сном. `get_identity_db` + в дефолтном режиме (`identity_store="tradein"`, он же прод) отдаёт ту же + сессию, что `get_db` — движок с QueuePool на 5+10 соединений. После SELECT в + `get_user_by_username` сессия держит соединение в открытой транзакции, и сон + внутри её области жизни превращал бы каждую спящую попытку в занятое + соединение: ~15 одновременных неудач выбирают пул целиком, и тогда ЛЮБОЙ + эндпоинт ждёт checkout 30с и падает. Отказ в обслуживании против всех сразу — + хуже той блокировки учётки, ради отказа от которой всё это писалось. + """ + fails = _USERNAME_FAIL_LIMITER.record(username) + delay_s = _throttle_delay_s(fails) + + schedule_event( + event_type="login_failed", + username=username, + ip=ip, + user_agent=user_agent, + path="/api/v1/auth/login", + method="POST", + # Состояние глобального счётчика — в аудит: по нему в user_events видно + # именно РАСПРЕДЕЛЁННЫЙ перебор (десятки неудач по одному имени с разных + # ip_address), который иначе выглядит как россыпь одиночных неудач. + payload={"username_fails_in_window": fails, "throttle_delay_s": delay_s}, + ) + + if delay_s > 0: + logger.warning( + "login throttle: username=%r fails=%d delay=%.1fs ip=%s", + username, + fails, + delay_s, + ip, + ) + # Соединение — в пул ДО сна (см. docstring). Сессия дальше не нужна: + # вызывающий немедленно делает raise, а повторный close() в самой + # зависимости идемпотентен. + db.close() + # await, не time.sleep: событийный цикл в это время обслуживает всех + # остальных — тормозим перебор, а не сервис. + await asyncio.sleep(delay_s) + + return HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL) + + @router.post("/login", response_model=LoginResponse) async def login( body: LoginRequest, @@ -111,6 +375,16 @@ async def login( headers={"Retry-After": str(int(retry_after) + 1)}, ) + # Гейт насыщения — ДО выборки из реестра (#2715). Заведомо отклоняемый + # запрос не берёт соединение из пула и не делает SELECT по имени: под + # насыщением это была бы единственная работа на пути отказа, а значит и + # единственное, чьё время зависит от существования учётки — bcrypt, который + # эту разницу ровняет, до отказанного запроса не доходит вовсе. Решение + # всё равно остаётся за `verify_password_bounded` ниже (тот же предикат, + # `except` под ним никуда не делся) — здесь только экономия похода в базу. + if verify_slots_saturated(ip): + raise _saturated_429(ip) + user = get_user_by_username(db, body.username) hash_to_check = ( user["password_hash"] @@ -119,20 +393,26 @@ async def login( ) # ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash # держит время ответа одинаковым независимо от существования аккаунта. - password_ok = verify_password(body.password, hash_to_check) + try: + # key=ip — доля слотов на адрес (#2714): один источник не занимает больше + # половины ёмкости, и вход остаётся открыт тем, кто приходит с других + # адресов. Ключ — ИМЕННО адрес, не имя: имя присылает клиент, и перебор + # менял бы его каждую попытку, получая полную долю на каждое. Границы + # применимости (IP подделывается за вторым прокси, разделяется за NAT, + # ротируется ботнетом) — в docstring `verify_password_bounded`. + password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip) + except PasswordVerifyOverloadedError: + # Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя — + # ждущий держит соединение к БД. Предчек выше сюда почти всё и отсекает, + # но авторитетен ИМЕННО ЭТОТ отказ, поэтому ветка остаётся. Ответ — + # тот же самый и с той же аргументацией, что у предчека: один helper, + # чтобы две ветки не разъехались (одинаковость 429 — часть защиты). + raise _saturated_429(ip) from None # Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние # доступа. Порядок несущий, а не стилистический: см. модульный docstring. if user is None or not password_ok: - schedule_event( - event_type="login_failed", - username=body.username, - ip=ip, - user_agent=user_agent, - path="/api/v1/auth/login", - method="POST", - ) - raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL) + raise await _reject_invalid_credentials(db, body.username, ip, user_agent) access_state = user["access_state"] if access_state is AccessState.TRIAL_EXPIRED: @@ -156,17 +436,9 @@ async def login( if not access_state.can_sign_in: # disabled (и любое нераспознанное состояние — to_access_state fail-closed) - # → ТОТ ЖЕ generic 401 и то же событие, что при неверном пароле: - # заблокированный аккаунт неотличим от несуществующего. - schedule_event( - event_type="login_failed", - username=body.username, - ip=ip, - user_agent=user_agent, - path="/api/v1/auth/login", - method="POST", - ) - raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL) + # → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном + # пароле: заблокированный аккаунт неотличим от несуществующего. + raise await _reject_invalid_credentials(db, body.username, ip, user_agent) token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent) diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index a5f1d106..6ac5ba07 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -21,6 +21,7 @@ from __future__ import annotations import logging import re +from datetime import UTC, datetime, timedelta from typing import Annotated, Any, Literal from uuid import UUID @@ -30,6 +31,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.api.v1.trade_in import _assert_estimate_access +from app.core.config import settings from app.core.db import get_db logger = logging.getLogger(__name__) @@ -115,17 +117,26 @@ async def create_trade_in_lead( # consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182, # ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет # X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден. + # + # ЭТАП 4 B2C: expires_at (migration 231) — раньше лид хранился бессрочно + # (никакого TTL вообще не было, в отличие от trade_in_estimates.expires_at). + # Считаем на insert-time тем же паттерном, что estimator.py делает для + # trade_in_estimates — retention-период вынесен в settings, не хардкод. + expires_at = datetime.now(tz=UTC) + timedelta(days=settings.trade_in_lead_retention_days) + row = ( db.execute( text( """ INSERT INTO trade_in_leads ( estimate_id, phone, consent, source, user_agent, - client_ip, consent_policy_version, consent_text_snapshot + client_ip, consent_policy_version, consent_text_snapshot, + expires_at ) VALUES ( CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent, - CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot + CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot, + :expires_at ) RETURNING CAST(id AS text), created_at """ @@ -139,6 +150,7 @@ async def create_trade_in_lead( "client_ip": client_ip, "consent_policy_version": _CONSENT_POLICY_VERSION, "consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT, + "expires_at": expires_at, }, ) .mappings() diff --git a/tradein-mvp/backend/app/api/v1/privacy_admin.py b/tradein-mvp/backend/app/api/v1/privacy_admin.py new file mode 100644 index 00000000..9ea0ee9d --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/privacy_admin.py @@ -0,0 +1,79 @@ +"""Admin right-to-erasure endpoint (152-ФЗ) — ЭТАП 4 B2C launch, part C. + +Auth не нужен в этом файле — вся ветка `/api/v1/admin/*` уже гейтится +`rbac_guard` middleware в app/main.py (`_ADMIN_API_RE`, role != admin → 403), +тем же паттерном, что app/api/v1/audit.py. + +Мутационный (DELETE), поэтому осторожно: это НЕ self-service для конечного +пользователя. Оператор поддержки/admin вызывает это ПОСЛЕ того, как убедился +(вне этого API — телефон/estimate-ссылка/переписка), что запрос на удаление +реально пришёл от владельца данных, а не от третьего лица, знающего чей-то +номер телефона. Идентификация анонима — см. app/services/data_erasure.py +module docstring (честно про то, что не всегда разрешимо). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.db import get_db + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class DataErasureRequest(BaseModel): + """Хотя бы одно поле обязательно — см. erase_person_data ValueError guard.""" + + username: str | None = Field(default=None, max_length=200) + estimate_ids: list[UUID] | None = None + phone: str | None = Field(default=None, max_length=32) + tg_chat_id: int | None = None + + +@router.post("/privacy/erase") +async def erase_person_data_endpoint( + payload: DataErasureRequest, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, dict[str, int]]: + """Физически удалить данные человека по одному или нескольким идентификаторам. + + Идентификаторы (хотя бы один): + - username — B2B-пилот: удаляет ВСЕ его оценки (created_by=username, CASCADE + подчищает фото/IMV-оценки), связанные лиды, веб-чат поддержки. + - estimate_ids — конкретные оценки по UUID (анонимный путь: человек прислал + ссылку/PDF со своим estimate_id) + лиды, привязанные к ним. + - phone — лиды с этим номером телефона (независимо от привязки к оценке). + - tg_chat_id — Telegram-поддержка (@MERAsupport_bot), включая переписку В + ЭТОЙ БД. НЕ удаляет зеркало в Telegram-топике (см. + app/services/data_erasure.py — ВАЖНЫЙ ФАКТ, честно, не скрываем). + + 422 если ни один идентификатор не передан (не даём случайно вызвать + "удали всё" пустым телом). + """ + if not any([payload.username, payload.estimate_ids, payload.phone, payload.tg_chat_id]): + raise HTTPException( + status_code=422, + detail="at least one identifier required: username / estimate_ids / phone / tg_chat_id", + ) + + from app.services.data_erasure import erase_person_data + + counters = await asyncio.to_thread( + erase_person_data, + db, + username=payload.username, + estimate_ids=payload.estimate_ids, + phone=payload.phone, + tg_chat_id=payload.tg_chat_id, + ) + logger.info("admin privacy erase requested -> %s", counters) + return {"deleted": counters} diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 3bfa7e73..2926e7e6 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -5,7 +5,9 @@ from __future__ import annotations +import asyncio import calendar +import json import logging from datetime import UTC, date, datetime, timedelta from typing import Annotated, Any @@ -52,6 +54,27 @@ logger = logging.getLogger(__name__) router = APIRouter() +# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404, +# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись +# по коду ответа; третий потребитель (`/r/`, PR-9) разошёлся бы +# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false +# в SQL — для всех существующих строк (retain_until IS NULL) поведение не +# меняется вообще. Не копировать это выражение по месту — только через +# константу/хелпер ниже. Payments retention, PR #2754. +ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())" + + +def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool: + """Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД. + + tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf) — + `.replace(tzinfo=UTC)`, не переизобретается. + """ + now = datetime.now(tz=UTC) + if expires_at.replace(tzinfo=UTC) > now: + return True + return retain_until is not None and retain_until.replace(tzinfo=UTC) > now + def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None: """IDOR guard (#690): только владелец оценки или admin могут её читать. @@ -146,6 +169,239 @@ def _resolve_target_house_id( return None +# ── Revival на GET /estimate/{id} (incident 2026-08-10) ───────────────────── +# Заказчик открыл сохранённую ссылку (?id=...) и увидел «НЕДОСТАТОЧНО ДАННЫХ»: +# запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825) и +# лежит в БД мёртвой (median_price<=0/NULL), хотя тот же адрес/параметры +# сейчас честно считаются. get_estimate() ниже пытается пересчитать такую +# строку ОДИН раз (throttled) через тот же estimate_quality(), что и POST +# /estimate, и пишет результат В ТУ ЖЕ строку (id/ссылка не меняются). Живую +# строку (median_price>0) этот путь не трогает вообще — сохранённая клиенту +# цена неприкосновенна. +def _precision_to_qc_geo(precision: str | None) -> int | None: + """Best-effort обратное отображение к estimator._qc_geo_to_precision. + + AggregatedEstimate наружу отдаёт только бакетированный address_precision + (house/street/approximate), не сырой dadata.qc_geo (0..5) — тот остаётся + приватным для estimate_quality(). При revival нам нужно записать ЧТО-ТО в + колонку dadata_qc_geo, чтобы будущие (уже НЕ revival, обычные) GET той же + теперь-живой строки не откатили address_precision в None. Бакеты 2..5 + (settlement/city/region/unknown) неразличимы ПОСЛЕ _qc_geo_to_precision — + 2 репрезентативно для всех: тот же helper на чтении схлопывает их обратно + в тот же "approximate", наблюдаемое поведение не меняется. + """ + if precision == "house": + return 0 + if precision == "street": + return 1 + if precision == "approximate": + return 2 + return None + + +def _payload_from_dead_row(row: Any) -> TradeInEstimateInput: + """Восстанавливает вход оценки из мёртвой сохранённой строки для revival. + + Только поля, реально персистящиеся в trade_in_estimates при создании + (address/lat/lon/area_m2/rooms/floor/total_floors/year_built/house_type/ + repair_state/has_balcony) — CRM-only поля (ownership_type/has_mortgage) + на расчёт не влияют и не нужны здесь. radius_m НИКОГДА не персистится + (payload.radius_m живёт только в рамках одного POST-запроса, ни главный + INSERT, ни _empty_estimate его не пишут) — None здесь даёт тот же + default-каскад (DEFAULT_RADIUS_M/FALLBACK_RADIUS_M), что у подавляющего + большинства сохранённых строк (явный радиус выбирает меньшинство). + consent=None + require_consent=False у вызывающего — revival не новое + согласие физлица, а служебный recompute уже существующей записи. + """ + return TradeInEstimateInput( + address=row.address, + area_m2=float(row.area_m2), + rooms=row.rooms, + floor=row.floor, + total_floors=row.total_floors, + year_built=row.year_built, + house_type=row.house_type, + repair_state=row.repair_state, + has_balcony=row.has_balcony, + lat=row.lat, + lon=row.lon, + radius_m=None, + consent=None, + ) + + +async def _try_revive_dead_estimate( + db: Session, estimate_id: UUID, row: Any +) -> AggregatedEstimate | None: + """Пытается пересчитать «мёртвую» (median_price<=0/NULL) строку на месте. + + Возвращает свежий AggregatedEstimate (estimate_id ПОДМЕНЁН на исходный — + id/ссылка не меняются) при успехе; None если: (а) throttle ещё не истёк / + заявку уже забрал параллельный запрос — anti-storm через атомарный + conditional `UPDATE ... RETURNING` ниже (тот же паттерн, что + account_quota.increment, #747): WHERE перепроверяет и «мертва ли строка + сейчас», и «давно ли последняя попытка» НЕПОСРЕДСТВЕННО в БД, а не по + значению, прочитанному раньше в Python — TOCTOU-гонка между двумя + параллельными GET невозможна, проигравший просто не дублирует работу; + (б) пересчёт сам дал 0 (по-прежнему недостаточно данных); (в) пересчёт + упал с исключением (сеть/геокод/что угодно). Во всех трёх случаях caller + обязан отдать сохранённую (по-прежнему мёртвую) строку как раньше — НЕ 500. + """ + claim = db.execute( + text( + """ + UPDATE trade_in_estimates + SET revival_attempted_at = NOW() + WHERE id = CAST(:id AS uuid) + AND (median_price <= 0 OR median_price IS NULL) + AND ( + revival_attempted_at IS NULL + OR revival_attempted_at + < NOW() - make_interval(mins => CAST(:throttle AS integer)) + ) + RETURNING id + """ + ), + {"id": str(estimate_id), "throttle": settings.trade_in_revival_throttle_minutes}, + ).fetchone() + db.commit() + if claim is None: + logger.info("estimate revival throttled/lost race: id=%s", estimate_id) + return None + + from app.services.estimator import estimate_quality + + try: + payload = _payload_from_dead_row(row) + result = await estimate_quality( + payload, + db, + created_by=row.created_by, + client_ip=None, + require_consent=False, + ) + except Exception: + logger.exception("estimate revival failed: id=%s address=%r", estimate_id, row.address) + return None + + temp_id = result.estimate_id + if result.median_price_rub <= 0: + logger.info("estimate revival still insufficient data: id=%s", estimate_id) + db.execute( + text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(temp_id)}, + ) + db.commit() + return None + + # estimate_quality() persists under a BRAND NEW uuid (temp_id) — it has no + # notion of "recompute this existing row". Copy the computed OUTPUT fields + # into the ORIGINAL row (id/link contract), then drop the throwaway one. + # INPUT snapshot (address/area/rooms/...) is untouched — it did not change, + # only the outputs were recomputed. + # #incident-2026-08-11: created_at is DELIBERATELY excluded from this SET — + # it is the client's original request date (printed in /history and in + # AggregatedEstimate.created_at, see app/schemas/trade_in.py:317-318), NOT + # a recompute output. It previously got clobbered with the throwaway temp + # row's created_at (=NOW() at recompute time), which also silently + # re-sorted the row to the top of `GET /history ORDER BY created_at DESC`. + # revival_completed_at (migration 256) is the audit trail for "when did a + # revival LAST successfully rewrite this row" — distinct from + # revival_attempted_at (255), which is stamped on every claim regardless + # of outcome (throttle loss / recompute failure included). + db.execute( + text( + """ + UPDATE avito_imv_evaluations + SET estimate_id = CAST(:orig AS uuid) + WHERE estimate_id = CAST(:temp AS uuid) + """ + ), + {"orig": str(estimate_id), "temp": str(temp_id)}, + ) + db.execute( + text( + """ + UPDATE trade_in_estimates SET + median_price = :median_price, + range_low = :range_low, + range_high = :range_high, + median_price_per_m2 = :median_ppm2, + confidence = :confidence, + confidence_explanation = :explanation, + n_analogs = :n_analogs, + analogs = CAST(:analogs_json AS jsonb), + actual_deals = CAST(:deals_json AS jsonb), + sources_used = CAST(:sources_json AS jsonb), + data_freshness_minutes = :freshness, + canonical_address = :canonical_address, + house_cadnum = :house_cadnum, + house_fias_id = :house_fias_id, + dadata_qc_geo = :dadata_qc_geo, + dadata_metro = CAST(:dadata_metro_json AS jsonb), + expected_sold_price = :expected_sold_price, + expected_sold_range_low = :expected_sold_range_low, + expected_sold_range_high = :expected_sold_range_high, + expected_sold_per_m2 = :expected_sold_per_m2, + asking_to_sold_ratio = :asking_to_sold_ratio, + ratio_basis = :ratio_basis, + relaxations = CAST(:relaxations_json AS jsonb), + reliability = :reliability, + revival_completed_at = NOW() + WHERE id = CAST(:id AS uuid) + """ + ), + { + "id": str(estimate_id), + "median_price": result.median_price_rub, + "range_low": result.range_low_rub, + "range_high": result.range_high_rub, + "median_ppm2": result.median_price_per_m2, + "confidence": result.confidence, + "explanation": result.confidence_explanation, + "n_analogs": result.n_analogs, + "analogs_json": json.dumps( + [a.model_dump(mode="json") for a in result.analogs], ensure_ascii=False + ), + "deals_json": json.dumps( + [a.model_dump(mode="json") for a in result.actual_deals], ensure_ascii=False + ), + "sources_json": json.dumps(result.sources_used, ensure_ascii=False), + "freshness": result.data_freshness_minutes, + "canonical_address": result.canonical_address, + "house_cadnum": result.house_cadnum, + "house_fias_id": result.house_fias_id, + "dadata_qc_geo": _precision_to_qc_geo(result.address_precision), + "dadata_metro_json": json.dumps(result.metro_nearest, ensure_ascii=False), + "expected_sold_price": result.expected_sold_price_rub, + "expected_sold_range_low": result.expected_sold_range_low_rub, + "expected_sold_range_high": result.expected_sold_range_high_rub, + "expected_sold_per_m2": result.expected_sold_per_m2, + "asking_to_sold_ratio": result.asking_to_sold_ratio, + "ratio_basis": result.ratio_basis, + "relaxations_json": json.dumps(result.relaxations, ensure_ascii=False), + "reliability": result.reliability, + }, + ) + db.execute( + text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(temp_id)}, + ) + db.commit() + + logger.info( + "estimate revived: id=%s median=%d n=%d confidence=%s reliability=%s", + estimate_id, + result.median_price_rub, + result.n_analogs, + result.confidence, + result.reliability, + ) + # created_at on the returned object must mirror the DB row (untouched + # original request date, NOT the temp row's NOW()) — see UPDATE above. + return result.model_copy(update={"estimate_id": estimate_id, "created_at": row.created_at}) + + @router.post("/estimate", response_model=AggregatedEstimate) async def estimate( payload: TradeInEstimateInput, @@ -171,8 +427,22 @@ async def estimate( # явный 503 — так любая БУДУЩАЯ реальная ошибка становится видимой, а не # «глотается» шлюзом. HTTPException пробрасываем как есть (это не сбой). # created_by (#656) прокидываем в estimate_quality для скоупа /history. + # ЭТАП 4 B2C (152-ФЗ): require_consent=True только когда нет + # X-Authenticated-User — сегодня rbac_guard (app/core/rbac.py) уже требует + # этот заголовок на любом non-public пути, так что эта ветка пока + # недостижима в проде (анонимный /estimate ещё не открыт другими частями + # ЭТАП 4/B2C работ) — гейт готов ЗАРАНЕЕ, на момент открытия анонимного + # доступа. client_ip — proof-of-consent (estimate_quality персистит его + # на trade_in_estimates только когда require_consent=True; B2B-пилоты + # остаются NULL, см. estimator.py::_estimate_consent_persist_fields). try: - result = await estimate_quality(payload, db, created_by=x_authenticated_user) + result = await estimate_quality( + payload, + db, + created_by=x_authenticated_user, + client_ip=_client_ip(request), + require_consent=x_authenticated_user is None, + ) except HTTPException: raise except Exception: @@ -235,21 +505,22 @@ def get_estimate( """ row = db.execute( text( - """ + f""" SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, address, lat, lon, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, dadata_qc_geo, dadata_metro, expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, - asking_to_sold_ratio, ratio_basis, created_by, created_at + asking_to_sold_ratio, ratio_basis, created_by, created_at, + relaxations, reliability FROM trade_in_estimates WHERE id = CAST(:id AS uuid) - AND expires_at > NOW() + AND {ESTIMATE_READABLE_SQL} """ ), {"id": str(estimate_id)}, @@ -260,6 +531,22 @@ def get_estimate( _assert_estimate_access(row.created_by, x_authenticated_user) + # #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана + # ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем + # пересчитать её на месте (throttled, race-safe — см. докстринг + # _try_revive_dead_estimate) через тот же путь, что и POST /estimate. + # Живую строку (median_price>0) не трогаем вообще. asyncio.run() — sync↔ + # async мост (тот же паттерн, что app/scheduler_main.py): get_estimate + # остаётся `def` (Starlette гоняет его в threadpool, как сейчас), поэтому + # ОСТАЛЬНЫЕ синхронные db.execute() ниже по функции не переезжают на event + # loop — только сама попытка revival временно занимает свой поток на время + # await estimate_quality(). Любая ошибка расчёта — не 500: revived is None, + # и функция просто продолжает как раньше, отдавая сохранённую строку. + if row.median_price is None or row.median_price <= 0: + revived = asyncio.run(_try_revive_dead_estimate(db, estimate_id, row)) + if revived is not None: + return revived + from app.services.estimator import ( _canonical_sources, _cv_from_ppm2, @@ -358,6 +645,7 @@ def get_estimate( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, @@ -395,6 +683,14 @@ def get_estimate( cv=cv, source_counts=source_counts, created_at=row.created_at, + # PR #2823 open follow-up (fixed incident 2026-08-10, migration 255): + # relaxations/reliability теперь персистятся — GET-rehydrate больше не + # теряет красный баннер «точность снижена» при открытии по ссылке. + # getattr defensive: старые in-memory test doubles / любая строка без + # этих колонок (не должно случаться после миграции) деградируют в + # дефолт схемы (ok / []), а не падают AttributeError. + relaxations=list(getattr(row, "relaxations", None) or []), + reliability=getattr(row, "reliability", None) or "ok", ) @@ -419,14 +715,15 @@ def estimate_pdf( SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, dadata_qc_geo, dadata_metro, expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, - asking_to_sold_ratio, ratio_basis, created_by + asking_to_sold_ratio, ratio_basis, created_by, + relaxations, reliability FROM trade_in_estimates WHERE id = CAST(:id AS uuid) """ @@ -439,8 +736,12 @@ def estimate_pdf( _assert_estimate_access(row.created_by, x_authenticated_user) - if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): - raise HTTPException(status_code=410, detail="estimate expired (24h TTL)") + # PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше + # здесь была независимая Python-проверка expires_at, разошедшаяся с SQL- + # фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при + # годовом retain_until упоминание 24ч в ответе API стало бы ложью. + if not estimate_readable(row.expires_at, row.retain_until): + raise HTTPException(status_code=410, detail="estimate expired") from app.services.estimator import _qc_geo_to_precision @@ -463,6 +764,7 @@ def estimate_pdf( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, @@ -482,6 +784,10 @@ def estimate_pdf( house_fias_id=row.house_fias_id, address_precision=_qc_geo_to_precision(row.dadata_qc_geo), metro_nearest=(row.dadata_metro or []), + # migration 255 — та же сноска «точность снижена», что и на JSON GET, + # теперь и в PDF-регенерации сохранённой оценки (см. get_estimate). + relaxations=list(getattr(row, "relaxations", None) or []), + reliability=getattr(row, "reliability", None) or "ok", ) input_snapshot = { "address": row.address, @@ -750,7 +1056,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: trade_in_estimates с непустым address; NULL при отсутствии адресов. NB: это честный best-effort по persisted оценкам, а не hit-rate реального кэша (отдельного счётчика попаданий не ведём). + + #2660: listings_active сам по себе врал — «активно» на проде не означает + «живо» (деактиватор протухших покрывает не все источники). Рядом отдаём + listings_active_stale — сколько из них не виделись listings_stale_days + (= LISTINGS_FRESH_DAYS эстиматора; прод 2026-08-05: 37 900 активных при + 20 935 не виденных 14+ дней). Счётчик не прячем, а разделяем. """ + from app.services.estimator import LISTINGS_FRESH_DAYS + row = ( db.execute( text( @@ -760,6 +1074,10 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: (SELECT count(*) FROM geocode_cache WHERE expires_at > NOW()) AS geocode_cache_fresh, (SELECT count(*) FROM listings WHERE is_active) AS listings_active, + (SELECT count(*) FROM listings + WHERE is_active + AND last_seen_at <= NOW() - (:fresh_days || ' days')::interval) + AS listings_active_stale, (SELECT max(scraped_at) FROM listings) AS listings_last_scraped, (SELECT count(*) FROM deals) AS deals, (SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings, @@ -776,12 +1094,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: WHERE address IS NOT NULL AND address <> '' ) t) AS repeat_address_pct """ - ) + ), + {"fresh_days": LISTINGS_FRESH_DAYS}, ) .mappings() .fetchone() ) - return dict(row) if row else {} + # Порог отдаём рядом с числом — чтобы UI подписывал «не виделись N дней», + # а не заводил второе определение свежести у себя. + return (dict(row) | {"listings_stale_days": LISTINGS_FRESH_DAYS}) if row else {} # ── Stage 4a: house info + IMV benchmark для UI ─────────────────────────────── @@ -1806,6 +2127,139 @@ def get_street_deals( # ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ────────────── +# #2666 гейт правдоподобия на «медианный торг». Пейринг ДКП↔объявление идёт по +# УЛИЦЕ без номера дома (data_quality="street_only", ADR #721): на длинной улице +# сделка и объявление могут стоять в разных домах и разных ценовых классах, и +# тогда discount_pct — не торг, а разница между двумя чужими друг другу лотами. +# Гард #2660 (миграция 211) убрал предвзятые пары «вторичка ↔ новостройка» и тем +# самым сделал остаток артефактов ВИДНЫМ: по `%Космонавтов%` 2-комн. медиана +# уехала с −11.9% на +36.4%, т.е. пользователю написали бы «продали на 36% +# дороже, чем просили». Здесь не чиним пейринг (это ADR-уровень), а перестаём +# показывать число, которому нельзя верить. +# +# Пороги подобраны по проду 2026-08-05 (симуляция эндпоинта на 238 РЕАЛЬНЫХ +# пользовательских запросах из trade_in_estimates — тот же address/area/rooms, +# что уходил в виджет; 128 из них дали хотя бы одну пару): +# +# MIN_PAIRS = 10 — бутстрап по 12 «плотным» группам (n ≥ 60 пар): из полной +# выборки берём подвыборку размера k и смотрим, насколько медиана подвыборки +# отклоняется от полной. p90 |отклонения|: k=5 → 18.8 п.п., k=10 → 12.0, +# k=15 → 9.9, k=20 → 8.2. Кривая ломается ровно на 10 (5→10 даёт −6.8 п.п. +# шума, 10→15 уже только −2.1, а каждые +5 к порогу стоят ещё ~8-10% улиц). +# Совпадает с уже принятым в продукте порогом малой выборки +# settings.sell_time_sensitivity_min_n_lots = 10. +# +# SANE_MIN/MAX = [−60%, +20%] — асимметричны намеренно, у сторон разная природа: +# ВЕРХ. В наблюдаемом распределении 128 групп положительный хвост РАЗОРВАН: +# +11.1, +10.8, +16.9 — и дальше пусто до +33.7, +34.2, +34.6, +39.0, +52.5, +# +70.2, +81.5, +103.1. Отсечка +20% попадает в пустой промежуток, т.е. режет +# отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один +# городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132 +# = −8.7% торга), так что «продали на +20% дороже ask» уже вдвое дальше любого +# рыночно объяснимого плюса. +# НИЗ. Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо: +# у большого отрицательного торга есть механизм (занижение цены в ДКП), в +# отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»: +# худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза +# глубже. Режет 6 групп из 128 (−87 … −64). +# +# Цена гейта на проде: из 128 групп с парами число сохраняют 64 (50%), 59 (46%) +# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся: +# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно +# строка «медианный торг», и вместо неё уходит median_discount_explanation. +# +# MIN_DISTINCT_LISTINGS = 2 (#2672) — ПАРЫ НЕ ЯВЛЯЮТСЯ НЕЗАВИСИМЫМИ НАБЛЮДЕНИЯМИ, +# и MIN_PAIRS этого не видит. DISTINCT ON подбирает по объявлению на сделку, но +# ОДНО объявление переиспользуется на многих сделках улицы: у показываемых групп +# медиана — 18 сделок на одно различное объявление. До этого порога из 64 +# показываемых чисел 22 (34%) стояли на ОДНОМ объявлении (худший живой кейс — +# `Белинского` 1-комн.: 50 пар, 1 объявление, −50.6%), 50 (78%) — меньше чем на +# трёх. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на +# ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира +# отличалась от типичной сделки, а не о торге на улице. +# +# Почему именно 2, и почему порог здесь обоснован ИНАЧЕ, чем MIN_PAIRS. Разброс +# со стороны объявлений мерили джекнайфом (выкинуть одно объявление, 45 групп, +# 118 повторов): p50 3.6, p90 18.8, max 80.4 п.п. — тот же порядок, что и шум +# при 5 парах, который при выборе MIN_PAIRS сочли неприемлемым. Но на группах с +# ОДНИМ объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще: +# выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0. +# Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по +# всем 64 группам от их добавления УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5), +# т.е. метрика становилась тем зеленее, чем больше в ней неизмеримого. Поэтому +# 2 — не статистический выбор, а граница выразимости: ниже неё нет выборки, о +# разбросе которой можно спрашивать, и показывать число = фабриковать точность. +# Выше 2 порог уже статистический, и данные (прод 2026-08-06, те же 128 групп) +# говорят, что он должен быть выше — но ценой почти всей витрины: +# объявлений ≥ 2 → 42 группы (33%), джекнайф p90 17.4; +# объявлений ≥ 3 → 14 групп (11%), p90 10.9 (планка MIN_PAIRS — 12.0); +# объявлений ≥ 4 → 7 групп ( 5%), p90 5.5. +# Порог 3 попадал бы в принятую планку шума, но оставляет 11% витрины и всё +# равно не делает число защищаемым (ошибка со стороны СДЕЛОК никуда не делась и +# складывается с ней). Выбирать между «9% покрытия» и «выключить строку» — +# решение владельца, не гейта; здесь снимается ровно то, что не является +# наблюдением рынка в принципе. Понижать MIN_PAIRS в компенсацию нельзя: +# вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность). +# +# SANE_MIN ужесточён −60% → −35% (#2672). Исходное подозрение «−60% режет живой +# рынок» проверено и ОПРОВЕРГНУТО: до −60% проходило всё, законный механизм +# большого минуса (занижение цены в ДКП) сохранён целиком. Ошибка была в другую +# сторону — граница пропускала неправдоподобный отрицательный хвост: 26 из 64 +# показываемых чисел (41%) лежали ниже −23.7%, худшего объяснимого рынком +# бакета (asking_to_sold_ratios: студии, ratio 0.7634, 1 519 сделок; ни один +# бакет не глубже), самое глубокое показываемое — −58.5%. Мы гасили «+34%» и +# показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга. Асимметрия +# работала против пользователя: абсурдный плюс сам себя опровергает («продали +# дороже, чем просили» — виджету просто не поверят), абсурдный минус выглядит +# правдоподобно и подталкивает продавца к выводу, что его улица торгуется за +# полцены. −35% ≈ в 1.5 раза глубже худшего рыночного бакета (запас на занижение +# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения −37.6 → −33.9. +# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → −46.5% показывался. +# +# ПОШТУЧНЫЙ discount_pct В СТРОКАХ ТАБЛИЦЫ (#2672 п.3). Гейт гасил сводное число, +# а таблица под ним продолжала показывать проценты, посчитанные из ТЕХ ЖЕ пар: +# на живом Космонавтове (2-комн., медиана −37.6% погашена) шесть из первых +# двенадцати строк — от +42% до +77%, и все против одной и той же цены +# предложения. Масштаб на проде 2026-08-07 (427 реальных запросов из +# trade_in_estimates, 135 групп с парами): медиана погашена у 102 групп, и в +# них видно 3 678 строк с процентом — 72.8% всех показываемых процентов. +# +# Гасим строку там, и только там, где причина — свойство САМОЙ ПАРЫ: +# а) объявлений < MIN_DISTINCT_LISTINGS — тогда столбец «разница» это +# столбец цены сделки, поделённый на одну и ту же константу: он не даёт +# ни одного наблюдения сверх уже показанных цен, но выглядит как N торгов; +# б) медиана вне санитарного диапазона — по определению медианы это +# утверждение О СТРОКАХ: половина из них ещё дальше от рынка, чем она. +# «Мало пар» строку НЕ гасит: это свойство ВЫБОРКИ, про отдельную пару оно +# ничего не говорит, а микрокопия «пар всего 4, поэтому процент в строке не +# показываем» была бы ложной причиной. Цена этого исключения — 3 группы / 20 +# строк на проде, где медианы нет, а проценты в строках есть. +# Флаги (а)/(б) считаются НЕЗАВИСИМО от порядка веток гейта: порядок «мало пар +# → одно объявление → диапазон» прячет вторую причину за первой, и на проде 55 +# групп гаснут как «мало пар», хотя стоят ещё и на ОДНОМ объявлении. По ветке +# гейта строки гасились бы не там, где надо. +# Цена на проде: из 5 054 строк с процентом гаснет 3 658 (72.4%), остаётся +# 1 396. Само число «медианный торг» этой правкой НЕ меняется — 33 группы из +# 135 и до, и после (замер обеих версий модуля в одном процессе на ОДНИХ И ТЕХ +# ЖЕ живых парах). Обе цены — сделки и объявления — в строке остаются: +# убирается не данные, а наша подпись «торг» под их разностью. +# +# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится): +# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721). +# Гейт по различным объявлениям честный промежуточный шаг, а не решение: +# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё +# сравнивают сделку в одном доме с объявлением в другом. +# 2. В группах, ПРОШЕДШИХ гейт, поштучные проценты остаются как есть — включая +# 426 строк из 1 396 (31%), лежащих вне того же диапазона [−35%, +20%], по +# которому мы гасим медиану. Отдельного порога для ОДНОЙ пары у нас нет: +# диапазон калиброван на медианах групп, а у одной сделки законный разброс +# шире (занижение цены в ДКП — механизм поштучный, не медианный). Считать +# его = вводить некалиброванный порог, чего #2672 прямо предостерегает. +SALES_VS_LISTINGS_MIN_PAIRS = 10 +SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2 +SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0 +SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0 + @router.get("/sales-vs-listings", response_model=SalesVsListingsResponse) def get_sales_vs_listings( @@ -1945,12 +2399,122 @@ def get_sales_vs_listings( discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None) median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None + # #2672: сколько РАЗЛИЧНЫХ объявлений стоит за этими парами. len(discounts) + # считает сделки, а не наблюдения рынка — одно объявление попадает в пару + # к десяткам сделок улицы (см. шапку секции). + n_distinct_listings = len( + {p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None} + ) + + # #2672 п.3: те же две проверки, но применённые к КАЖДОЙ СТРОКЕ таблицы, а не + # к сводному числу (обоснование — в шапке секции, блок «ПОШТУЧНЫЙ ПРОЦЕНТ»). + # Считаются ДО гейта, потому что гейт обнуляет median_discount, и порядок его + # веток (мало пар → одно объявление → диапазон) прячет вторую причину за + # первой: на проде 55 групп гасятся как «мало пар», хотя стоят ещё и на ОДНОМ + # объявлении. Для строк важна причина, а не то, какая ветка сработала раньше. + pairs_stand_on_one_listing = n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS + median_is_implausible = median_discount is not None and not ( + SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT + <= median_discount + <= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT + ) + + # #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо + # отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь + # прочитает как поломку, а не как честность. + median_discount_explanation: str | None = None + if median_discount is not None: + if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS: + # Формулировка — ФАКТ про выборку, а не обещание надёжности выше + # порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше — + # пары псевдореплики), обещать «от 10 надёжно» мы не вправе. + median_discount_explanation = ( + f"Медианный торг не показываем: пар «сделка ↔ объявление» всего " + f"{len(discounts)} — на такой выборке медиана гуляет на десятки " + f"процентных пунктов." + ) + elif pairs_stand_on_one_listing: + # Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1» + # грамматично при любом значении, «на 1 различных объявлений» — нет. + median_discount_explanation = ( + f"Медианный торг не показываем: сделок {len(discounts)}, а разных " + f"объявлений для сравнения всего {n_distinct_listings} — такой процент " + f"говорит о цене одной конкретной квартиры, а не о торге на улице." + ) + elif median_is_implausible: + # Типографский минус (U+2212) — как в fmtDiscount на фронте. + shown = f"{median_discount:+.1f}".replace("-", "−") + # Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно + # следующим блоком это говорит street_only-дисклеймер (карточка) / + # хвост note (v2-mappers). Проверено скриншотом — две формулировки + # подряд читались как стена текста. + median_discount_explanation = ( + f"Медианный торг не показываем: расчёт дал неправдоподобное значение " + f"({shown}%) — такого торга на рынке не бывает." + ) + if median_discount_explanation is not None: + logger.info( + "sales-vs-listings: median_discount gated street=%r rooms=%d " + "n_pairs=%d distinct_listings=%d value=%+.2f%%", + street_name, + rooms, + len(discounts), + n_distinct_listings, + median_discount, + ) + median_discount = None + + # #2672 п.3: под погашенной медианой строки таблицы продолжали показывать + # проценты из ТЕХ ЖЕ пар (живой кейс — Космонавтов: +76%, +73%, +63% против + # одной и той же цены предложения). Гасим их там, и только там, где причина — + # свойство самой пары; «мало пар» свойство ВЫБОРКИ, про отдельную строку оно + # ничего не говорит, поэтому одну строку не трогает (обоснование и цена — + # в шапке секции). Обе цены остаются в строке: мы убираем не данные, а нашу + # подпись «торг» под разностью, которой не можем ручаться. + if discounts and (pairs_stand_on_one_listing or median_is_implausible): + if pairs_stand_on_one_listing: + # Оба числа названы совместно с фразой медианы: там «сделок N», здесь + # «одна и та же цена» — читателю видно и сколько строк, и на скольких + # объявлениях они стоят. + row_explanation = ( + "Проценты по каждой сделке тоже не показываем: все они считаются " + "против одной и той же цены объявления." + ) + else: + # Медиана вне диапазона — это утверждение О СТРОКАХ: по определению + # медианы половина из них лежит по дальнюю сторону от неё, т.е. тоже + # вне рыночного диапазона. Значение здесь НЕ повторяем: в ветке + # диапазона оно уже названо предыдущим предложением (вышло бы дважды + # в одном абзаце), а в ветке «мало пар» мы его намеренно не + # показываем — и печатать его в пояснении было бы отказом на словах. + row_explanation = ( + "Проценты по каждой сделке тоже не показываем: половина из них — " + "за пределами того, как торгуется рынок." + ) + for pair in pairs: + pair.discount_pct = None + median_discount_explanation = ( + f"{median_discount_explanation} {row_explanation}" + if median_discount_explanation + else row_explanation + ) + logger.info( + "sales-vs-listings: per-row discount_pct gated street=%r rooms=%d rows=%d " + "distinct_listings=%d reason=%s", + street_name, + rooms, + len(discounts), + n_distinct_listings, + "one_listing" if pairs_stand_on_one_listing else "implausible_median", + ) logger.info( - "sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s", + "sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d " + "linkage=%.1f%% median_disc=%s", street_name, total_deals, deals_with_listings, + n_distinct_listings, linkage_rate_pct, f"{median_discount:+.2f}%" if median_discount is not None else "n/a", ) @@ -1964,6 +2528,7 @@ def get_sales_vs_listings( deals_with_listings=deals_with_listings, linkage_rate_pct=linkage_rate_pct, median_discount_pct=median_discount, + median_discount_explanation=median_discount_explanation, # street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) → # даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им. data_quality="street_only" if total_deals > 0 else "no_data", diff --git a/tradein-mvp/backend/app/api/v1/version.py b/tradein-mvp/backend/app/api/v1/version.py new file mode 100644 index 00000000..86729727 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/version.py @@ -0,0 +1,20 @@ +"""GET /api/v1/trade-in/version — build metadata (product version + short SHA + +build date), source `app/core/version.py`. + +Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) — это не +секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только +process env / уже загруженные при импорте константы (без похода в БД).""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA + +router = APIRouter() + + +@router.get("/version") +def get_version() -> dict[str, str]: + """{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}.""" + return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE} diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 8c44ca32..9030fd88 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -109,6 +109,61 @@ class Settings(BaseSettings): login_rate_limit_window_s: int = Field( default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S" ) + # Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571). + # Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого + # нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО + # имя, поэтому попытки со всех адресов складываются. + # + # Превышение порога НЕ блокирует учётку (это был бы вектор DoS против + # конкретного человека — злоумышленник выключал бы чужой вход по своему + # желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час + # выбран так, чтобы живой человек с опечатками до него не доходил. + login_username_fail_threshold: int = Field( + default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD" + ) + login_username_fail_window_s: int = Field( + default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S" + ) + # Потолок задержки одного ответа. Держим невысоким сознательно: задержка — + # это ещё и цена, которую платит легитимный владелец имени, пока его + # перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг». + login_username_throttle_max_delay_s: float = Field( + default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S" + ) + # ── #2665: проверка пароля вне событийного цикла + СОЗНАТЕЛЬНЫЙ потолок ──── + # Замер в прод-контейнере 2026-08-06: bcrypt cost 12 (все живые хеши — + # `$2b$12$`) = 282 мс медиана. Пока `verify_password` звался прямо в + # `async def login`, эти 282 мс были простоем ВСЕГО API, и они же были + # единственным настоящим потолком темпа логинов — замерено 3.6 попытки/с при + # стойле событийного цикла до 836 мс. Обе половины чинятся вместе, см. + # `app.core.password.verify_password_bounded`. + # + # `workers` — это и есть потолок темпа: не больше workers/282мс проверок в + # секунду, сколько бы соединений ни пришло. Дефолт 1 выбран так, чтобы + # ПОСЛЕ выноса в пул потолок остался тем же (~3.5/с), что случайно давала + # блокировка цикла: вынос не должен ускорять перебор. Поднимать имеет смысл + # только вместе с осознанным ответом «во сколько раз мы согласны ускорить + # перебор ради параллельных входов». + # ge=1: 0 или -1 роняют ThreadPoolExecutor прямо НА ИМПОРТЕ («max_workers must + # be greater than 0») — контейнер уходит в crash-loop, и причина видна только + # в трейсбеке старта. Пусть отказ будет на валидации настроек, с именем поля. + login_password_verify_workers: int = Field( + default=1, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_WORKERS" + ) + # Сколько запросов одновременно допускаются к проверке (считая тех, кто ждёт + # очереди в пуле). Сверх — сразу 429, без ожидания. Не режет темп (его режут + # workers), а держит конечной ОЧЕРЕДЬ: каждый ждущий запрос удерживает + # соединение к БД (сессия реестра открыта после SELECT в + # `get_user_by_username`), а в QueuePool их всего 5+10. Неограниченная + # очередь выбрала бы пул и положила API ровно так же, как блокировка цикла, + # только другим способом. 4 из 15 соединений и худшее ожидание + # 4/1×282мс ≈ 1.1с — цена, которую живой вход переживает. + # ge=1: 0 читается как «выключить лимит», а означал бы обратное — КАЖДЫЙ вход + # получает 429 навсегда и молча (слотов нет ни одного). Выключать тут нечего: + # потолок — это workers, а очередь без границы выбирает пул соединений к БД. + login_password_verify_max_inflight: int = Field( + default=4, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_MAX_INFLIGHT" + ) # ── Эпик «единый вход»: общий реестр людей в БД `auth` ───────────────────── # DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и @@ -773,6 +828,64 @@ class Settings(BaseSettings): # допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS. avito_stale_ttl_days: int = 10 + # ── ЭТАП 4 B2C launch — retention / erasure (152-ФЗ) ──────────────────── + # trade_in_estimates.expires_at TTL (часы от момента создания). Раньше был + # хардкод `timedelta(hours=24)` в estimator.py (x2: главный INSERT + + # _empty_estimate fallback) — вынесено в настройку, чтобы retention-период + # не требовал правки кода. 24ч — продуктовое решение MVP (оценка живёт + # "сессию" клиента, не архив); юридически обоснованный срок хранения адреса + # физлица для анонимного B2C — решение не инженера, см. итоговый комментарий + # к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS. + trade_in_estimate_retention_hours: int = 24 + + # trade_in_leads.expires_at TTL (дни от момента создания, migration 231). + # У trade_in_leads раньше вообще не было срока хранения — лид (телефон + + # согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для + # НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон + # 90-180 дней при отсутствии дальнейшего договорного отношения с клиентом); + # если лид конвертировался в реальную сделку/договор — для него должен + # действовать ДРУГОЙ (договорной) срок хранения, но в кодовой базе нет + # механизма отметки "лид конвертирован" — этого разграничения здесь НЕТ, + # см. итоговый комментарий к задаче (конкретный юридически обоснованный + # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. + trade_in_lead_retention_days: int = 180 + + # ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ───── + # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ + # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность + # расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см. + # migration 240 докстринг. Отдельная колонка, а не подъём expires_at: + # expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков + # для всех строк, поднять его до года = соврать в документе клиента про + # свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов. + # Единственный источник числа «12 месяцев» на фронте — + # `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты, + # экран после оплаты и SQL продления retain_until при оплате (платёжный + # код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе + # классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране + # «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. + trade_in_paid_retention_days: int = 365 + + # ── Revival на GET /estimate/{id} (incident 2026-08-10) ───────────────── + # Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL) + # сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F, + # PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается + # пересчитать такую строку через тот же estimate_quality(), что и POST + # (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза + # в это число минут на строку — иначе каждый refresh страницы бил бы по + # геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс: + # достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро, + # чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV: + # TRADE_IN_REVIVAL_THROTTLE_MINUTES. + trade_in_revival_throttle_minutes: int = 10 + + # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним + # DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама + # крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) — + # это ограничивает ОДНУ транзакцию, не общий прогресс. ENV: + # TRADE_IN_PURGE_BATCH_SIZE. + trade_in_purge_batch_size: int = 500 + # ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ───────────────────── # Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России» # (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug @@ -877,5 +990,36 @@ class Settings(BaseSettings): # message_thread_id топика внутри support-группы, в который идут зеркала. telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID") + # ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ────────── + # См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит + # роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch. + # PAYMENTS_ENABLED=false (дефолт) держит контур выключенным полностью: + # ни один из последующих PR (C/D/E) не должен активироваться без явного + # включения в .env.runtime прод-стека. + tbank_terminal_key: str = Field(default="", validation_alias="TBANK_TERMINAL_KEY") + # Пароль терминала — участвует в подписи Token (Init) и проверке подписи + # входящих нотификаций. SecretStr по прецеденту auth_db_password (строка + # 197 выше): не должен всплыть в логах/repr/Sentry breadcrumbs. + tbank_password: SecretStr = Field(default=SecretStr(""), validation_alias="TBANK_PASSWORD") + tbank_api_base_url: str = Field( + default="https://securepay.tinkoff.ru", validation_alias="TBANK_API_BASE_URL" + ) + tbank_notification_url: str = Field(default="", validation_alias="TBANK_NOTIFICATION_URL") + tbank_success_url: str = Field(default="", validation_alias="TBANK_SUCCESS_URL") + tbank_fail_url: str = Field(default="", validation_alias="TBANK_FAIL_URL") + # "O" — одностадийная (оплата сразу), "T" — двухстадийная (холд + Confirm). + # Дефолт "T": выбрана схема с холдом (гибрид «Проба → холд → отчёт по + # ссылке», ядро — вариант B) — источник решения `mera-b2c-paid-flow- + # decision.md` §1 в корне репо, НЕ recon-док (тот сам по себе выбирает + # "O" — устарел этим решением). Не переставляй дефолт обратно на "O", не + # сверившись с decision-доком. + tbank_pay_type: Literal["O", "T"] = Field(default="T", validation_alias="TBANK_PAY_TYPE") + tbank_receipt_enabled: bool = Field(default=False, validation_alias="TBANK_RECEIPT_ENABLED") + tbank_taxation: str = Field(default="", validation_alias="TBANK_TAXATION") + tbank_ffd_version: str = Field(default="", validation_alias="TBANK_FFD_VERSION") + # Kill-switch всего контура. false — checkout/notify (появятся в PR-D) + # обязаны отказывать сразу, ничего не вызывая у T-Bank. + payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED") + settings = Settings() diff --git a/tradein-mvp/backend/app/core/password.py b/tradein-mvp/backend/app/core/password.py index 9616d5bf..374d55a2 100644 --- a/tradein-mvp/backend/app/core/password.py +++ b/tradein-mvp/backend/app/core/password.py @@ -5,14 +5,28 @@ bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8) `hash_password` явно ловит это и падает с ValueError вместо тихого поведения. `verify_password` на длинном пароле возвращает False (не raise) — сравнение паролей не должно ронять запрос авторизации. + +#2665: из `async def` зови ТОЛЬКО `verify_password_bounded` — см. её docstring. +Синхронный `verify_password` остаётся для sync-кода (сидов, тестов, CLI) и как +тело, которое исполняется в пуле. + +Правило про пул относится к СВЕРКЕ, не к хешированию. `hash_password` — тот же +cost 12 и те же ~282 мс на цикле — сознательно остаётся синхронным в +`app/api/v1/team.py` (заведение сотрудника, смена пароля): это редкая операция +АУТЕНТИФИЦИРОВАННОГО менеджера, её нельзя вызвать анонимно и потому нельзя +превратить в поток. Станет их много — переносить тем же приёмом. """ from __future__ import annotations +import asyncio import logging +from concurrent.futures import ThreadPoolExecutor import bcrypt +from app.core.config import settings + logger = logging.getLogger(__name__) _BCRYPT_MAX_BYTES = 72 @@ -59,3 +73,209 @@ def verify_password(plain: str, hashed: str) -> bool: # Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login. logger.warning("verify_password: malformed hash rejected: %s", e) return False + + +class PasswordVerifyOverloadedError(RuntimeError): + """Свободных слотов на проверку пароля нет. Вызывающий обязан ответить 429.""" + + +# Пул, в котором крутится bcrypt. `max_workers` — не тюнинг пропускной +# способности, а САМ ПОТОЛОК ТЕМПА: проверок в секунду не больше, чем +# workers / 282мс, независимо от числа соединений. Читается один раз на импорте +# — размер пула по определению статичен (см. `login_password_verify_workers`). +_VERIFY_POOL = ThreadPoolExecutor( + max_workers=settings.login_password_verify_workers, + thread_name_prefix="pw-verify", +) + +# Сколько проверок сейчас в работе ИЛИ ждут очереди в пуле. Обычный int без +# лока — намеренно: и инкремент, и декремент выполняются в потоке событийного +# цикла, между чтением и записью нет ни одного `await`, так что чередования +# внутри пары нет. Счётчик, а не `asyncio.Semaphore`: мы никогда не ЖДЁМ на нём +# (сверх лимита — сразу отказ), а int не имеет привязки к конкретному циклу и +# потому одинаково честен под несколькими event loop'ами в тестах. +_verify_inflight = 0 + +# То же самое, но в разрезе ключа (#2714). Запись живёт РОВНО пока ключ держит +# хотя бы слот и удаляется на нуле: размер словаря ограничен числом слотов +# (`login_password_verify_max_inflight`), а не числом когда-либо виденных +# адресов — иначе перебор с ротацией IP растил бы его без границы. +_verify_inflight_by_key: dict[str, int] = {} + + +def _per_key_slot_cap() -> int: + """Сколько слотов из общего лимита разрешено ОДНОМУ ключу. + + Половина — минимальное деление, при котором один источник, сколько бы он ни + слал, физически не может занять всё: вторая половина остаётся тем, кто + приходит впервые. Настройкой не сделано сознательно — это доля, а не + величина, и подкручивать её нечем: 100% возвращает поведение, ради отказа + от которого правка написана. + + Читается на каждом вызове, а не на импорте, — как `_throttle_delay_s`: + иначе тестовый monkeypatch лимита не влиял бы на долю. + + `max(1, …)`: при `max_inflight=1` половина округлилась бы в 0, и КАЖДЫЙ вход + получал бы отказ молча (свободных слотов нет ни у кого). Молчаливый отказ + всем — ровно тот класс поломки, от которого страхует `ge=1` на самой + настройке; здесь тот же страховочный пол, но от деления. + """ + return max(1, settings.login_password_verify_max_inflight // 2) + + +def verify_slots_saturated(key: str) -> bool: + """Тот же предикат, по которому отказывает `verify_password_bounded`, но БЕЗ взятия слота. + + Нужен вызывающему ровно затем, чтобы отказать ДО похода в БД (#2715). Гейт + стоял ПОСЛЕ выборки пользователя, и каждый заведомо отклоняемый запрос всё + равно брал соединение из пула и делал SELECT по имени — тогда, когда система + уже перегружена. Хуже того, под насыщением эта выборка оставалась + ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, который ровняет время ответа + для существующего и несуществующего имени, ниже по течению и до него не + доходит, так что разницу «строка найдена / не найдена» ничто не маскировало. + + Предчек, а не решение: авторитетная проверка остаётся внутри + `verify_password_bounded` — она зовёт ЭТУ ЖЕ функцию, так что разъехаться + двум условиям нечем, и инвариант «одна точка выноса = одна точка учёта» + цел (слот здесь не резервируется и не отдаётся). + + Учитывает и общий потолок, и долю на ключ (#2714) — иначе предчек не + покрывал бы главный случай: при флуде с ОДНОГО адреса первым упирается + именно доля, и большинство отказов снова ходило бы в базу. + """ + return ( + _verify_inflight >= settings.login_password_verify_max_inflight + or _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap() + ) + + +async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool: + """`verify_password`, унесённая с событийного цикла И с сознательным потолком темпа (#2665). + + ДВЕ ПОЛОВИНЫ ОДНОЙ ПРАВКИ, И ЖИВУТ ОНИ ЗДЕСЬ ВМЕСТЕ НЕ ИЗ ЛЮБВИ К ПОРЯДКУ. + Порознь каждая делает хуже, чем было: + - вынести bcrypt в пул, не поставив потолок → перебор УСКОРЯЕТСЯ (замер + ниже: 3.6/с → 16/с на дефолтном executor'е); + - поставить потолок, не вынося bcrypt → 282 мс простоя всего API на каждую + попытку остаются. + Поэтому единственная точка выноса в поток и единственная точка учёта слотов — + одна и та же функция: состояние «вынесено, но потолка нет» невыразимо. + + Замер в прод-контейнере (2026-08-06, cost 12, все живые хеши `$2b$12$`): + verify_password = 282 мс медиана; + вызов прямо в `async def` — 3.6 проверки/с, стойло событийного цикла 836 мс + (это и был «потолок» — случайный, ценой отказа в обслуживании всего API); + `asyncio.to_thread` без потолка — 16 проверок/с, стойло 6 мс. + Отсюда дефолт `workers=1`: потолок остаётся тем же ~3.5/с, что был, а API + перестаёт стоять. Числа перепроверяемы: tests/test_password.py. + + Потолок держится ПРОЦЕССОМ, а не общим хранилищем. Это проверено, а не + предположено: прод-бэкенд запущен `uvicorn app.main:app` без `--workers` + (один процесс), а `REDIS_URL` в окружении tradein-backend НЕ ЗАДАН вовсе + (`printenv | grep -c ^REDIS_URL=` → 0, находка эпика #2674 — кэш поиска всю + жизнь стучится в localhost и получает отказ). Потолок на Redis был бы + потолком, который молча не работает. + Ceiling: появятся `--workers N` (или `WEB_CONCURRENCY=N` в `.env.runtime` — + uvicorn читает число процессов и оттуда, а файл правится руками на VPS) — + темп множится на N, как и у соседних in-memory лимитеров в + app/api/v1/auth.py; тогда потолок надо переносить в общее хранилище, + предварительно убедившись, что оно реально доступно. + + ДОЛЯ НА КЛЮЧ (#2714). Слоты — общий котёл, и потолок исправно бил по своим: + пока флуд держал все четыре, легитимный вход с ВЕРНЫМ паролем получал 429 + столько раз, сколько пытался. Поэтому *key* (у единственного вызывающего — + IP клиента) не берёт больше `_per_key_slot_cap()`: сколько бы один источник + ни слал, половина ёмкости остаётся тем, кто приходит впервые. Учёт по ключу + живёт ЗДЕСЬ ЖЕ и отдаётся тем же `_release_verify_slot` — инвариант «одна + точка выноса = одна точка учёта» не делится надвое. + + Чего это НЕ делает, и это не оговорка ради приличия. Ключом может быть + только IP, а IP: + - подделывается, если между нами и клиентом окажется ещё один прокси + (сейчас доверенный хоп ровно один — Caddy, `ratelimit._client_ip` берёт + правый элемент XFF; появится второй — ключ станет клиентским вводом); + - разделяется: за NAT/корпоративным шлюзом вся организация приходит с + одного адреса и делит одну долю с чужим перебором. СОСЕДЯМ ПО АДРЕСУ + СТАЛО ХУЖЕ, и это честный размен, а не побочный эффект: при флуде в + 3 запроса/с с того же адреса свои входят 69% попыток против 94% до + правки, а порог, за которым сосед перестаёт входить, падает с ~14 до + ~7 запросов/с. Взамен вход С ЧУЖИХ адресов идёт 100% против 37%; + размен принят сознательно — офис за одним NAT это единицы адресов, + а «все остальные» это все; + - меняется: ботнет или ротация прокси дают злоумышленнику столько ключей, + сколько ему нужно, и доля на ключ перестаёт быть ограничением. + То есть это ПОДНИМАЕТ СТОИМОСТЬ атаки (одного адреса больше не хватает, + чтобы закрыть вход всем), но не закрывает её. Закрывают принципиально + только доказательство работы на входе или второй фактор — отдельный разговор + и отдельная цена. + + Raises: + PasswordVerifyOverloadedError: очередь на проверку заполнена + (`login_password_verify_max_inflight`) ЛИБО *key* уже держит свою + долю (`_per_key_slot_cap`). Отказ мгновенный: ждать нельзя, ждущий + запрос держит соединение к БД. Оба случая неразличимы снаружи + намеренно — отказ приходит ДО сверки и потому ничего не сообщает о + том, существует ли учётка. + """ + global _verify_inflight + + # АВТОРИТЕТНАЯ проверка. Вызывающий может спросить то же самое заранее + # (`verify_slots_saturated`, #2715), но решение принимается здесь и только + # здесь — предчек экономит поход в БД, а не заменяет этот отказ. + if verify_slots_saturated(key): + raise PasswordVerifyOverloadedError + + loop = asyncio.get_running_loop() + _verify_inflight += 1 + _verify_inflight_by_key[key] = _verify_inflight_by_key.get(key, 0) + 1 + try: + work = _VERIFY_POOL.submit(verify_password, plain, hashed) + except BaseException: + # Работа в пул НЕ встала — колбэка не будет, слот отдаём здесь. Иначе + # утёкший слот навсегда отнимает у входа часть и без того малой ёмкости. + _release_verify_slot(key) + raise + + # Слот освобождает ЗАВЕРШЕНИЕ РАБОТЫ, а не выход из этой корутины. Отмена + # (клиент отвалился, таймаут) прекращает корутину, но УЖЕ НАЧАТУЮ сверку не + # снимает — поток занят ею все 282 мс. Отдавай мы слот в `finally`, на это + # время слот считался бы свободным: одновременно работающих сверок стало бы + # больше, чем разрешено, и очередь пула поехала бы вслед за ними. + # (Ещё не начатую работу отмена как раз снимает — `cancel()` пробрасывается + # на future пула, — так что вреда от неё нет; проблема ровно в начатой.) + # + # Именно поэтому колбэк висит на future ПУЛА, а не на обёртке из + # `run_in_executor`: у обёртки «готово» наступает и при отмене — тест + # `test_bounded_slot_freed_by_the_work_not_by_cancellation` ловит эту разницу. + work.add_done_callback(lambda _f: _schedule_verify_slot_release(loop, key)) + return await asyncio.wrap_future(work) + + +def _schedule_verify_slot_release(loop: asyncio.AbstractEventLoop, key: str) -> None: + """Возвращает слот по факту завершения работы в пуле (см. вызывающую). + + Колбэк future пула исполняется В ПОТОКЕ ПУЛА, а счётчики — собственность + потока событийного цикла (на том и держится арифметика без лока), поэтому + декремент переносим в цикл через `call_soon_threadsafe`. + """ + try: + loop.call_soon_threadsafe(_release_verify_slot, key) + except RuntimeError: + # Цикл уже закрыт (остановка процесса) — освобождать нечего и некому. + logger.debug("verify slot release skipped: event loop is closed") + + +def _release_verify_slot(key: str) -> None: + """Единственное место, где слот отдают: и общий счётчик, и счётчик ключа. + + Оба — одним движением и здесь же, а не по одному на каждом пути выхода: + разъедься они, и достаточно забыть одну строчку, чтобы ключ навсегда унёс + с собой долю ёмкости, которую никто уже не вернёт. + """ + global _verify_inflight + _verify_inflight -= 1 + left = _verify_inflight_by_key.get(key, 0) - 1 + if left > 0: + _verify_inflight_by_key[key] = left + else: + _verify_inflight_by_key.pop(key, None) diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index 2b809ddb..f5f3fe04 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -114,8 +114,13 @@ class SlidingWindowLimiter: return self._window_s - (now - bucket[0]) return None - def record(self, key: str) -> None: - """Регистрирует одну успешную попытку под *key*.""" + def record(self, key: str) -> int: + """Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё. + + Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login + (#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа + пропорционально. Значение можно игнорировать — `check()` так и делает. + """ now = time.monotonic() bucket = self._hits[key] self._prune(bucket, now) @@ -125,6 +130,7 @@ class SlidingWindowLimiter: if len(self._hits) > 10000: for k in [k for k, v in self._hits.items() if not v]: del self._hits[k] + return len(bucket) def check(self, key: str) -> float | None: """Комбинированная проверка+регистрация (peek+record за один вызов) — diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index e8c5fc04..0dd654ff 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset( "/api/v1/trade-in/support/anon/messages", "/api/v1/trade-in/support/anon/unread", "/api/v1/trade-in/support/anon/read", + # Версионирование (VERSION-файл + build-args, см. app/core/version.py): + # не секрет, читает только process env — быстрая справка для клиента/ + # поддержки/смоук-теста, не должна требовать сессию. + "/api/v1/trade-in/version", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/app/core/version.py b/tradein-mvp/backend/app/core/version.py new file mode 100644 index 00000000..44101a3a --- /dev/null +++ b/tradein-mvp/backend/app/core/version.py @@ -0,0 +1,83 @@ +"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`. + +`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env, +запечённые в образ через build-args в `backend/Dockerfile` +(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же +ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и +`date -u +%Y-%m-%dT%H:%M:%SZ`. + +Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env — +тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов, +см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта +модуля. Ничего здесь не должно падать при отсутствии env (потребитель — +и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`). + +Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда. +Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION` +в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и +`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля / +`@/lib/buildInfo` соответственно; не заводи третий. +""" + +from __future__ import annotations + +import datetime as dt +import os +from pathlib import Path + +_DEFAULT_VERSION = "0.0.0" +# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает +# и локальный layout (backend/app/core/version.py → ../../../VERSION == +# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/ +# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION). +_MAX_ANCESTORS = 6 + + +def _find_version_file() -> Path | None: + here = Path(__file__).resolve() + for ancestor in list(here.parents)[:_MAX_ANCESTORS]: + candidate = ancestor / "VERSION" + if candidate.is_file(): + return candidate + return None + + +def _read_version_file() -> str: + path = _find_version_file() + if path is None: + return _DEFAULT_VERSION + try: + text = path.read_text(encoding="utf-8").strip() + except OSError: + return _DEFAULT_VERSION + return text or _DEFAULT_VERSION + + +def _default_build_date() -> str: + return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# Читаются один раз при импорте модуля (совпадает с паттерном `settings = +# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем, +# перечитывать на каждый запрос незачем. +APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file() +BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev" +BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date() + + +def format_build_date_human(build_date: str = BUILD_DATE) -> str: + """ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF + колонтитул). Никогда не бросает исключение — при неразборчивой строке + возвращает её как есть (это футер отчёта, не API-контракт).""" + try: + parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return build_date + return parsed.strftime("%d.%m.%Y") + + +def product_version_line(product_name: str = "Мера") -> str: + """`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта + 2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF + колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`.""" + return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index bea049b8..347cad8c 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -29,10 +29,12 @@ from app.api.v1 import ( geocode, lead, me, + privacy_admin, search, support, team, trade_in, + version, ) from app.core.auth_db import get_auth_engine from app.core.config import settings @@ -143,6 +145,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: get_auth_engine() logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован") + # Платёжный контур МЕРЫ (Т-Банк эквайринг, PR-B — схема-only). Роутер/сервис + # ещё не существуют (PR-D), но kill-switch и конфиг уже здесь: если кто-то + # включит PAYMENTS_ENABLED=true в .env.runtime раньше, чем заведёт + # TBANK_TERMINAL_KEY/TBANK_PASSWORD, лучше не поднимать контейнер вообще, + # чем молча остаться без терминала/пароля на боевом трафике оплат. + if settings.payments_enabled and ( + not settings.tbank_terminal_key or not settings.tbank_password.get_secret_value() + ): + raise RuntimeError( + "PAYMENTS_ENABLED=true, но TBANK_TERMINAL_KEY/TBANK_PASSWORD пусты — " + "задай оба в .env.runtime до включения платёжного контура" + ) + # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # Best-effort: failure does not abort startup, just logs. try: @@ -199,8 +214,10 @@ 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"]) app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"]) +app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) +app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"]) app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"]) app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) diff --git a/tradein-mvp/backend/app/schemas/search.py b/tradein-mvp/backend/app/schemas/search.py index 62d9e5f2..d6cd6ae7 100644 --- a/tradein-mvp/backend/app/schemas/search.py +++ b/tradein-mvp/backend/app/schemas/search.py @@ -40,7 +40,9 @@ class SearchParams(BaseModel): floors_total_max: int | None = Field(default=None, ge=1) # --- Quality / cross-source --- - has_kadastr: bool = False + # has_kadastr снят (#2674): listings.cadastral_number пуст у всех 93 408 строк, + # фильтр мог вернуть только пустую выдачу. Лишний query-param FastAPI игнорирует, + # так что старые клиенты не ломаются. sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None multi_source_only: bool = False require_avito: bool = False diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 3df09fe1..b7cb3556 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -48,6 +48,15 @@ class TradeInEstimateInput(BaseModel): has_mortgage: bool | None = None # client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167). + # ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 229). + # Enforcement (НЕ здесь): app.services.estimator.estimate_quality проверяет + # `created_by is None and not consent -> 422` ДО первого INSERT адреса в + # trade_in_estimates. Здесь поле намеренно `bool | None = None`, а НЕ + # `Literal[True]` (как TradeInLeadInput.consent) — сделать True строго- + # обязательным на уровне Pydantic сломало бы B2B-пилотов: их согласие + # закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе. + consent: bool | None = None + class AnalogLot(BaseModel): address: str @@ -187,6 +196,10 @@ class AggregatedEstimate(BaseModel): analogs: list[AnalogLot] actual_deals: list[AnalogLot] # реальные продажи last 12 mo expires_at: datetime + # PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности + # расчёта — тот остаётся expires_at (не путать, см. migration 240). + # NULL = неоплачено (весь текущий трафик, B2B pilots включительно). + retain_until: datetime | None = None # ── Дополнительные метаданные ── target_address: str | None = None # geocoded full address target_lat: float | None = None @@ -306,6 +319,41 @@ class AggregatedEstimate(BaseModel): cv: float | None = None source_counts: dict[str, int] = Field(default_factory=dict) created_at: datetime | None = None + # ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10, + # #oblast-E priority RESTORED same day — see estimator.py module + # docstring for the full 3-way headline-source rule) ────────────────── + # Product requirement: an estimate is ALWAYS surfaced — a thin base sample + # (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First + # estimator.estimate_quality() progressively relaxes the analog SEARCH + # (room-count adjacency → freshness window → novostroyki segment → radius) + # trying to grow the sample past the threshold; if it's STILL thin, + # _price_from_inputs() prefers a usable ДКП deals corridor over a noisy + # thin listings median when one is available (restored #oblast-E + # priority — the Серов repro: 3 listings must not outrank 54 deals), and + # only falls back to the thin listings median itself when no corridor + # exists. Real refusal happens only at genuine zero (no listings AND no + # usable anchor/deals). + # relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага + # ослабления, готовые к показу пользователю как честный дисклеймер рядом с + # confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад + # не понадобился (обычный случай). Возможные значения (дословно, фронт + # может на них завязываться): "снят фильтр по году постройки", + # "учтены студии", "комнатность ±1", "объявления за 60 дней", + # "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м", + # "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП + # deals corridor because the base listings sample was thin — a source + # SWITCH, not a search widening, but surfaced the same way). + # reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs + # (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если + # relaxations непусто (выборка набралась только ценой ослаблений); капается + # на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой + # выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего». + # НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное + # ограничение, каскад не переигрывается из сохранённых analogs). НЕ + # путать с `confidence` (Literal low/medium/high — старая метрика на + # основе уникальных адресов/IQR, см. её собственный докстринг выше). + relaxations: list[str] = Field(default_factory=list) + reliability: Literal["ok", "low", "very_low"] = "ok" # ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку # при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ── area_m2: float | None = None @@ -417,6 +465,11 @@ class ScheduleConfigUpdate(BaseModel): window_start_hour: int = Field(default=2, ge=0, le=23) window_end_hour: int = Field(default=5, ge=0, le=23) default_params: dict[str, Any] = Field(default_factory=dict) + # #2674: явная воля оператора по времени следующего запуска. None (умолчание) — + # «не трогай, посчитай сам от такта». Заданное значение уважается как есть, включая + # прошедшее/now() — это и есть «запустить сейчас» (планировщик берёт строки с + # next_run_at <= NOW()), у которого до сих пор не было API и его делали UPDATE'ом. + next_run_at: datetime | None = None # ── House analytics (house_placement_history backfill) ─────────────────────── @@ -604,6 +657,13 @@ class SalesVsListingsResponse(BaseModel): deals_with_listings: int # сколько имеют связанный listing linkage_rate_pct: float # deals_with_listings / total_deals * 100 median_discount_pct: float | None # медиана по парам с listing + # #2666: None вместе с median_discount_pct=None означает «медианы просто нет» + # (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт + # правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги + # SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается. + # Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен + # текст «почему числа нет», иначе пустое место читается как поломка виджета. + median_discount_explanation: str | None = None data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3) pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC diff --git a/tradein-mvp/backend/app/services/cian_price_history.py b/tradein-mvp/backend/app/services/cian_price_history.py index fdf1518f..0e23310e 100644 --- a/tradein-mvp/backend/app/services/cian_price_history.py +++ b/tradein-mvp/backend/app/services/cian_price_history.py @@ -19,19 +19,40 @@ from dataclasses import dataclass, field # golden-parity была доказана против legacy cian_detail-модуля до его удаления, # #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным # legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь). -# RealScraperConfig — тот же read-only адаптер над settings, что и остальные -# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url) -# идентичным прежнему прямому импорту settings. from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment +from scraper_kit.proxy_errors import NoProxyAvailableError from sqlalchemy import text from sqlalchemy.orm import Session -from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig +from app.services.scraper_adapters import ( + RealMatcherAdapter, + RealProxyProvider, + RealScraperConfig, +) from app.services.scraper_settings import get_scraper_delay logger = logging.getLogger(__name__) +class _PoolCurlConfig(RealScraperConfig): + """RealScraperConfig с принудительно включённым pool-режимом curl (#2830). + + `USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml + services.scraper.environment), а этот бэкфилл запускается ручкой + `POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет + → `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url` + ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`: + один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод). + + Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути + пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему. + """ + + @property + def use_proxy_pool_curl(self) -> bool: + return True + + @dataclass class CianPriceHistoryResult: checked: int = 0 @@ -60,6 +81,11 @@ async def backfill_cian_price_history( result = CianPriceHistoryResult() t0 = time.time() delay = get_scraper_delay("cian") # default 5.0s + # Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает + # `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт + # (mark_banned на CianBlockedError / mark_health / release). + scraper_config = _PoolCurlConfig() + proxy_provider = RealProxyProvider() if listing_id is not None: rows = ( @@ -107,9 +133,27 @@ async def backfill_cian_price_history( url: str = row["source_url"] try: - # config= обязателен — kit fetch_detail без него не читает cian_proxy_url - # (direct connection), а без прокси datacenter-IP блокируется Cian (#806). - enrichment = await fetch_detail(url, config=RealScraperConfig()) + # config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси + # datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула + # (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про + # `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался. + enrichment = await fetch_detail( + url, config=scraper_config, proxy_provider=proxy_provider + ) + except NoProxyAvailableError as exc: + # Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги + # упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с + # логом, который читается как «Циан нас блокирует». + logger.error( + "cian_price_history: нет доступного прокси в пуле (%s) — батч прерван " + "на listing_id=%s (обработано %d из %d)", + exc, + lid, + i, + len(rows), + ) + result.errors += 1 + break except Exception as exc: logger.warning( "cian_price_history: fetch failed listing_id=%s url=%s: %s", diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py index d6d68bf2..f40378e2 100644 --- a/tradein-mvp/backend/app/services/cian_session.py +++ b/tradein-mvp/backend/app/services/cian_session.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import logging +from datetime import datetime from typing import Any from curl_cffi.requests import AsyncSession @@ -20,9 +21,15 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings +from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url_sync logger = logging.getLogger(__name__) +# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ +# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания +# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое. +COOKIE_EXPIRY_WARN_DAYS = 5 + # Cookies критичные для Cian auth — фильтр перед сохранением. # Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23). # Старые записи оставлены как fallback (backward compat). @@ -146,8 +153,12 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None: try: # proxies: mobile-proxy egress (#806) — Cian блокирует datacenter-IP даже # при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403. - # Пусто (env не задан) → прямое подключение (dev/no-op). - _proxy_url = settings.cian_proxy_url + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.cian_proxy_url только если + # пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/ + # нездоровы для cian -- ProxyPoolExhaustedError (fail-closed, #2616), см. except + # ниже. + _proxy_url = resolve_proxy_url_sync("cian") _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None async with AsyncSession( impersonate="chrome120", @@ -184,6 +195,17 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None: logger.info("Cian cookies verified — userId=%s", user.get("userId")) return result + except ProxyPoolExhaustedError as exc: + # Fail-closed (#2616, #2825): пул scrape_proxies не пуст, но все узлы забанены + # ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый + # статичный узел мог быть источником бана, см. proxy_egress module docstring). + # Явный отказ вместо слепого прохода через заведомо подозрительный egress. + logger.error( + "Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, " + "cookies НЕ помечены протухшими, retry на следующем такте", + exc, + ) + return VERIFY_SOURCE_UNAVAILABLE_SENTINEL except Exception as exc: # Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник # недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде @@ -294,6 +316,37 @@ def load_session(db: Session) -> dict[str, str] | None: return cookies +def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None: + """Когда протухают самые свежезагруженные куки (#2658). + + `load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на + протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от + «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. + + valid_only=False (диагностика после None от load_session) — свежайшая запись любая: + валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же + запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок + ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке. + """ + row = db.execute( + text( + """ + SELECT expires_at_estimate FROM cian_session_cookies + WHERE NOT CAST(:valid_only AS boolean) + OR (expires_at_estimate > NOW() + AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)) + ORDER BY uploaded_at DESC + LIMIT 1 + """ + ), + {"valid_only": valid_only}, + ).first() + if row is None: + return None + expires_at: datetime | None = row[0] + return expires_at + + def mark_session_invalid(db: Session, account_user_id: int) -> None: """Flag session как expired/invalid (например после 401 во время scrape).""" db.execute( diff --git a/tradein-mvp/backend/app/services/data_erasure.py b/tradein-mvp/backend/app/services/data_erasure.py new file mode 100644 index 00000000..05a84bba --- /dev/null +++ b/tradein-mvp/backend/app/services/data_erasure.py @@ -0,0 +1,240 @@ +"""Right-to-erasure mechanism (152-ФЗ) — ЭТАП 4 B2C launch, part C. + +WHY: + trade_in has no self-service "delete my data" endpoint at all. Both B2B + pilots (identified by `created_by` username) and future anonymous B2C + users need SOME way to have their personal data physically removed on + request, not just after their retention TTL expires + (app/tasks/purge_expired_trade_in_data.py handles the TTL path, this + module handles the on-demand path). + +WHO CAN BE IDENTIFIED, HONESTLY: + - B2B pilot (has a `username`): trivially -- `created_by = username` scopes + every estimate they created; leads/support threads follow from there. + - Anonymous person: has NO username. This function can ONLY act on + identifiers the requester can actually supply: + * `estimate_ids` -- if they still have the link/PDF from their estimate + (the UUID in the URL/QR-code IS their proof of "this is mine"). + * `phone` -- if they left a contact-request lead with that phone. + Matched by CANONICAL RU DIGITS on both sides (see + `_ru_phone_norm_sql` below), not an exact string: lead.py stores + `payload.phone` exactly as typed (no E.164 normalization, by + design), so "+7 999 123-45-67", "8 (999) 123-45-67" and + "89991234567" must all find the same row. Covers ONLY the + RU 8-vs-7 trunk-prefix case (exact digit-count identity, no + heuristic truncation) -- see the helper's docstring for why. + * `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own + Telegram chat id -- not guessable/spoofable by a third party the way + a name or IP would be). + If an anonymous person has NONE of these (e.g. they only remember the + street address, or ran an estimate but never saved anything and didn't + log support contact) -- THIS IS HONESTLY UNRESOLVABLE without additional + identification. There is no username, no stable session, nothing in the + DB schema today that lets a support operator find "the one estimate this + specific stranger made three days ago" among many. Do not paper over + this: an operator facing that case must say so, not silently pick "the + closest match". + +⚠️ TELEGRAM CAVEAT (152-ФЗ, honestly, do not omit): + Every tg_support_messages row was, at send time, ALSO mirrored by the bot + into the support-group Telegram topic (see app/services/tgbot/bridge.py, + 186_tg_support.sql). Deleting `tg_support_users` here only removes the + copy IN THIS DATABASE. The mirrored copy lives in the Telegram supergroup, + outside this function's reach, and is NOT deleted by anything in this + codebase. A complete erasure across the whole chain requires a SEPARATE + manual step (Telegram Bot API `deleteMessage` per `topic_message_id` in + the supergroup) that is out of scope here. Do not cite this function's + return value as proof of full erasure of the Telegram-side copy. + +WHAT ELSE IS *NOT* TOUCHED (known gap, flagged, not silently dropped): + `user_events` (184_user_events.sql) logs `estimate_request` events with a + JSONB payload that includes `address`/`area_m2`/`rooms` and is keyed by + `username` (empty string for anonymous callers today) + `ip_address`, with + NO FK to trade_in_estimates (decoupled/append-only by explicit design -- + see that migration's comment). This function does NOT purge user_events: + it is an audit/analytics log, not an estimate/lead/support record, and + deciding whether "audit trail" is a legitimate 152-ФЗ retention basis that + overrides an erasure request is a legal call, not an engineering one. Flag + it to whoever handles the request; do not assume it is already covered. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def _ru_phone_norm_sql(expr: str) -> str: + """SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр, + ведущая '7'), для сравнения "разного форматирования одного и того же номера" + (deep-review 2026-08-06, MEDIUM + follow-up). + + Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с + ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской + нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина + проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально + НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений + неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми + цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем + неудаление своих. Номера другой длины/страны просто не совпадут ни на + этом шаге, ни дальше -- безопасный отказ, не false positive. + + `expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или + `CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит + структуру запроса из литералов, вызывающих её мест ровно два (см. + _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон + не попадает в текст SQL напрямую, только через bind-параметр `:phone`. + """ + stripped = f"regexp_replace({expr}, '\\D', '', 'g')" + return ( + f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' " + f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)" + ) + + +# Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data +# (колонка trade_in_leads.phone / входной CAST(:phone AS text)). +_PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone") +_PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)") + + +def erase_person_data( + db: Session, + *, + username: str | None = None, + estimate_ids: Sequence[UUID] | None = None, + phone: str | None = None, + tg_chat_id: int | None = None, +) -> dict[str, int]: + """Physically delete a person's data across trade_in tables. + + At least one identifier is required (raises ValueError otherwise -- callers + MUST pass an explicit identifier, never "erase everything" by omission). + + Order of operations matters: leads are captured/deleted BEFORE estimates, + because trade_in_leads.estimate_id is ON DELETE SET NULL (172) -- once the + estimate row is gone, the join key to find "leads that came from this + person's estimate" is gone too. + + Returns per-table deleted-row counters. Callers own committing the ambient + Session lifecycle in whatever way their layer does (this function DOES + commit itself, mirroring app/tasks/*.py conventions, since this is a + one-shot admin operation, not a request-scoped unit of work shared with + other writes). + """ + if not any([username, estimate_ids, phone, tg_chat_id]): + raise ValueError( + "erase_person_data requires at least one identifier: " + "username / estimate_ids / phone / tg_chat_id" + ) + + counters: dict[str, int] = { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 0, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + + # 1. Собрать ПОЛНЫЙ набор estimate_id ДО удаления оценок: явные estimate_ids + # (анонимный путь -- человек прислал ссылку/PDF) + все id с + # created_by=username (B2B-путь). Нужно захватить это СЕЙЧАС -- после + # DELETE FROM trade_in_estimates связанные trade_in_leads.estimate_id + # уйдут в NULL (ON DELETE SET NULL, 172), join станет невозможен. + all_estimate_ids: set[UUID] = set(estimate_ids or []) + if username: + owned = ( + db.execute( + text("SELECT id FROM trade_in_estimates WHERE created_by = :username"), + {"username": username}, + ) + .scalars() + .all() + ) + all_estimate_ids.update(owned) + + # 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по + # телефону (лид мог быть оставлен без attach к оценке вовсе). + # + # ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом + # (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py + # сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная + # E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN), + # т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67" + # ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone` + # находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ + # формат, каким когда-то ввёл номер -- почти никогда так. Раньше это + # молча удаляло 0 строк и всё равно возвращало 200 "данные удалены": + # для 152-ФЗ ложное подтверждение удаления хуже честной ошибки. + # _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql + # выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую + # '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс, + # без усечения до "последних 10 цифр" (риск ложного совпадения с + # номером другой страны -- см. докстринг helper'а). Номера иных + # форматов/длин сравниваются как есть (просто не совпадут). Параметр -- + # CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL + # injection convention, .claude/rules/backend.md); сам SQL-текст + # собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет + # ни одного значения параметра. + ids_param = [str(i) for i in all_estimate_ids] + result = db.execute( + text( + f""" + DELETE FROM trade_in_leads + WHERE estimate_id = ANY(CAST(:ids AS uuid[])) + OR ( + CAST(:phone AS text) IS NOT NULL + AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL} + ) + """ + ), + {"ids": ids_param, "phone": phone}, + ) + counters["trade_in_leads_deleted"] = result.rowcount or 0 + + # 3. Оценки (CASCADE подчищает estimate_photos + avito_imv_evaluations). + if all_estimate_ids: + result = db.execute( + text("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": ids_param}, + ) + counters["trade_in_estimates_deleted"] = result.rowcount or 0 + + # 4. Веб-чат поддержки -- ключ username (сайт закрыт Caddy basic_auth, у + # анонима username нет и быть не может, см. 187_web_support_chat.sql). + if username: + result = db.execute( + text("DELETE FROM web_support_threads WHERE username = :username"), + {"username": username}, + ) + counters["web_support_deleted"] = result.rowcount or 0 + + # 5. Telegram-поддержка -- ключ chat_id, ЕДИНСТВЕННЫЙ путь, реально + # доступный анониму без username (см. module docstring). ⚠️ Чистит + # ТОЛЬКО эту БД -- Telegram-топик со своей копией переписки НЕ + # затрагивается, см. ВАЖНЫЙ ФАКТ в docstring выше. + if tg_chat_id is not None: + result = db.execute( + text("DELETE FROM tg_support_users WHERE chat_id = CAST(:chat_id AS bigint)"), + {"chat_id": tg_chat_id}, + ) + counters["tg_support_deleted"] = result.rowcount or 0 + + db.commit() + logger.info( + "erase_person_data: username=%r estimate_ids=%d phone=%s tg_chat_id=%s -> %s", + username, + len(all_estimate_ids), + "" if phone else None, + tg_chat_id, + counters, + ) + return counters + + +__all__: list[str] = ["erase_person_data"] diff --git a/tradein-mvp/backend/app/services/domclick_session.py b/tradein-mvp/backend/app/services/domclick_session.py index 177ee772..85fdef97 100644 --- a/tradein-mvp/backend/app/services/domclick_session.py +++ b/tradein-mvp/backend/app/services/domclick_session.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import logging +from datetime import datetime from sqlalchemy import text from sqlalchemy.orm import Session @@ -25,6 +26,13 @@ from app.core.config import settings logger = logging.getLogger(__name__) +# За сколько дней до протухания кук предупреждать (#2674, по образцу #2658 для Циана). +# Обновление кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас: +# сигнал по факту протухания приходит, когда обогащение уже встало. Прод 2026-08-03: +# куки протухли, единственным следом был WARNING в docker-логе, который к тому же +# теряется при редеплое. save_session ставит ttl 30 дней, так что окно широкое. +COOKIE_EXPIRY_WARN_DAYS = 5 + # Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением. # Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной # test-аккаунт сессии (Sber ID login), 2026-07-04. @@ -148,6 +156,37 @@ def load_session(db: Session) -> dict[str, str] | None: return cookies +def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None: + """Когда протухают самые свежезагруженные куки (#2674, зеркалит cian_session #2658). + + `load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на + протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от + «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. + + valid_only=False (диагностика после None от load_session) — свежайшая запись любая: + валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же + запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок + ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке. + """ + row = db.execute( + text( + """ + SELECT expires_at_estimate FROM domclick_session_cookies + WHERE NOT CAST(:valid_only AS boolean) + OR (expires_at_estimate > NOW() + AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)) + ORDER BY uploaded_at DESC + LIMIT 1 + """ + ), + {"valid_only": valid_only}, + ).first() + if row is None: + return None + expires_at: datetime | None = row[0] + return expires_at + + def mark_session_invalid(db: Session, account_cas_id: int) -> None: """Flag session как expired/invalid (например после блока во время scrape).""" db.execute( diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 11b7dff1..a7e1da29 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -33,6 +33,7 @@ from datetime import UTC, date, datetime, timedelta from typing import Any, Literal from uuid import uuid4 +from fastapi import HTTPException from scraper_kit.providers.avito.imv import ( IMVAddressNotFoundError, IMVAuthError, @@ -47,6 +48,7 @@ from scraper_kit.providers.cian.valuation import ( estimate_via_cian_valuation, ) from scraper_kit.providers.yandex.valuation import ( + ValuationHouseMeta, YandexValuationResult, YandexValuationScraper, ) @@ -95,6 +97,69 @@ MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на # оттуда нельзя (estimator сам тянет area_bucket из этой задачи → цикл импорта). DEALS_PERIOD_MONTHS = 12 # сделки за последний год +# ── ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ) ──────────────── +# Отдельная пара от _CONSENT_POLICY_VERSION/_CONSENT_TEXT_SNAPSHOT в +# app/api/v1/lead.py: там согласие на КОНТАКТ-заявку (обработка телефона для +# CRM), здесь — согласие на САМУ ОЦЕНКУ (обработка адреса + параметров квартиры +# для расчёта стоимости). Разный предмет обработки -> разный текст/версия, даже +# если оба сейчас датированы одним месяцем. +# +# Enforcement: `require_consent` — явный keyword-only флаг у estimate_quality(), +# НЕ вывод из created_by is None. Причина: estimate_quality() — единственная +# ФУНКЦИЯ, но с ДЕСЯТКАМИ прямых вызовов из тестов эстиматора (test_same_ +# building_anchor.py, test_estimator_quarter_index.py и т.д.), почти все зовут +# её как estimate_quality(payload, db) без created_by вообще — они тестируют +# ценовую логику, не auth. Если бы gate триггерился от created_by is None, +# ЛЮБОЙ такой тест внезапно стал бы "анонимным" и падал на 422 (проверено: +# сломал 92 теста при первой попытке этого PR). require_consent=False по +# умолчанию -> НИ ОДИН существующий вызов не меняет поведение. Единственный +# реальный (production) вызывающий — app/api/v1/trade_in.py::estimate() — +# явно передаёт require_consent=(x_authenticated_user is None), т.е. gate +# реально применяется РОВНО там и тогда, где решение принимает rbac (нет +# X-Authenticated-User => анонимный запрос). +# +# СИНХРОННОСТЬ С ФРОНТОМ: сегодня анонимный флоу ещё не открыт (rbac_guard +# требует X-Authenticated-User на ЛЮБОМ non-public path — см. app/core/rbac.py), +# поэтому у этого текста пока НЕТ живого фронтового чекбокса для сверки (в +# отличие от _CONSENT_TEXT_SNAPSHOT в lead.py, см. +# tests/test_consent_text_frontend_sync.py). Когда анонимный /estimate откроется +# и фронт получит свой чекбокс — добавь сюда симметричный sync-тест ПРЕЖДЕ, чем +# полагаться на комментарий (ровно та ошибка, которую эта задача чинит для лидов). +_ESTIMATE_CONSENT_POLICY_VERSION = "2026-07" +_ESTIMATE_CONSENT_TEXT_SNAPSHOT = ( + "Согласен(-на) на обработку персональных данных (адрес объекта и параметры " + "квартиры) в целях предварительной оценки стоимости в соответствии с " + "Федеральным законом «О персональных данных» № 152-ФЗ" +) + + +def _estimate_consent_persist_fields( + require_consent: bool, client_ip: str | None +) -> dict[str, Any]: + """Поля consent/client_ip/policy/snapshot для INSERT в trade_in_estimates. + + require_consent=True -> здесь мы УЖЕ прошли gate в начале estimate_quality() + (payload.consent is True гарантирован), поэтому пишем durable-доказательство. + require_consent=False (B2B-путь, дефолт для всех прочих вызывающих) -> все + четыре NULL: согласие закрыто договором либо вызывающий вообще не участвует + в consent-контракте (внутренние тесты/скрипты) — доказательство здесь не + собиралось и собираться не должно. + """ + if require_consent: + return { + "consent": True, + "client_ip": client_ip, + "consent_policy_version": _ESTIMATE_CONSENT_POLICY_VERSION, + "consent_text_snapshot": _ESTIMATE_CONSENT_TEXT_SNAPSHOT, + } + return { + "consent": None, + "client_ip": None, + "consent_policy_version": None, + "consent_text_snapshot": None, + } + + # #oblast-D (non-EKB deals-headline-fallback): минимум ДКП-сделок, чтобы # _fetch_dkp_corridor доверял СВОЕЙ street-scoped выборке — иначе (тонкая # конкретная улица небольшого города) виджет расширяется до city-wide (см. @@ -122,6 +187,24 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3 # ИТОГОВОЙ выборке как headline-источнику. HEADLINE_LISTINGS_MIN_N = 5 +# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live +# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4 +# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34; +# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10). +# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового +# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка +# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять +# параметры подбора (см. #oblast-F блок там), от наименее к наиболее +# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент +# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в +# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт +# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а +# только помечает результат как низконадёжный. +RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1 +LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней +RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000) +RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум + # #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals. # Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm² # to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ). @@ -828,10 +911,15 @@ def _save_yandex_history_items( (address|publish_date|area|floor|prices) hash. Batch semantics: single try/except; on any failure the batch rolls back. - """ - if not result.history_items: - return 0 + #2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по + пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но + без единого объявления в истории, до записи панорамы не доходила — на проде это + 1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена + переноса: match_or_create_house теперь вызывается и для таких страниц (может + СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на + остальных 90% — новых сущностей класс не появляется, появляется недостающая доля. + """ # Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16] # — stable across re-runs, distinguishes pages for different addresses. address_seed = (result.address or "").strip().lower() @@ -867,6 +955,12 @@ def _save_yandex_history_items( result.address, ) + # Наблюдение о доме не зависит от того, есть ли на странице история объявлений. + _save_yandex_house_panorama(db, house_id, result.house) + + if not result.history_items: + return 0 + rows = [] skipped_area = 0 for item in result.history_items: @@ -948,6 +1042,60 @@ 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() + + # ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── @@ -2450,12 +2598,28 @@ class PricingResult: # headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV # радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных). cv: float | None = None - # #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N - # (headline suppressed, listings_clean deliberately left intact — see gate - # comment above). Caller uses this to also keep the thin listings out of the - # display `analogs` cards when no anchor overrides the headline. 0 = either - # sufficient listings were used, or genuinely zero were found. + # #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже + # HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет + # headline/listings_clean — median/n_analogs остаются реальными, поле лишь + # маркирует «низкая надёжность» (confidence='low' + честный explanation, + # см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов + # вообще не нашлось. listings_headline_thin_n: int = 0 + # #oblast-E (restored priority, product correction 2026-08-10): True когда + # headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая + # выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен + # достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже). + # Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись + # «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на + # 'low' (не выше). False во всех остальных случаях, включая genuinely-zero + # listings deals-fallback (тот же блок, но без тонкой выборки позади). + deals_headline_due_to_thin_listings: bool = False + + +def _analog_word_dative(n: int) -> str: + """Дательный падеж существительного «аналог» для confidence_explanation + тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам».""" + return "аналогу" if n == 1 else "аналогам" def _price_from_inputs( @@ -2534,45 +2698,82 @@ def _price_from_inputs( n_analogs = 0 cv = None - # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above). - # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust - # as a market median — suppress the AGGREGATE (median/range/n_analogs/cv) - # exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/ - # insufficient_data chain below all take the already-honest zero-analogs - # path automatically (no new branches there). `listings_clean` itself is - # deliberately LEFT INTACT (not cleared) — the same-building anchor's own - # ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell - # "genuinely zero nearby listings" from "some nearby listings, just too few - # to trust as THIS estimate's headline" — those are different confidence - # signals and clearing the list here would conflate them. The caller - # (estimate_quality) uses `listings_headline_thin_n` on the returned - # PricingResult to also keep suppressed listings out of the display - # `analogs` cards when no anchor overrides the headline (n_analogs - # invariant: cards shown ⊆ what n_analogs counts). + # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above) + # — priority RESTORED 2026-08-10 (product correction on top of #oblast-F): + # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy + # enough to headline on their OWN — a more reliable source should win when + # one exists. Two sub-cases: + # (i) a usable ДКП deals corridor is available (same threshold the + # #oblast-D deals-headline-fallback block below itself requires, + # DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the + # listings AGGREGATE is suppressed to zero here so that block takes + # over the headline, EXACTLY like original #oblast-E. This is the + # Серов repro this gate exists for: n=3 listings must not outrank a + # 54-deal corridor. `listings_clean` stays intact (never cleared) — + # both for the anchor ghost-anchor guard (#1871) AND so + # estimate_quality() still surfaces these listings as display + # `analogs` cards even though they no longer drive n_analogs/median. + # (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the + # real thin median rather than refusing outright. By the time + # control reaches this function, estimate_quality() has already run + # the #oblast-F relaxation cascade (room-adjacency / freshness / + # novostroyki / radius) trying to grow the sample past the + # threshold — `listings` here is whatever that cascade could find. + # NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from + # the `deals_headline_due_to_thin_listings` PricingResult field set later — + # this one fires as soon as the gate DECIDES to cede (used below to skip + # the repair-coefficient/explanation blocks safely, regardless of whether + # anchor later overrides); the field fires only once the #oblast-D + # deals-headline-fallback block ACTUALLY builds the headline from deals + # (anchor may still override in between — see that block). listings_headline_thin_n = 0 + gate_ceded_to_deals = False + # Outward PricingResult field — set True below, ONLY inside the actual + # #oblast-D deals-headline-fallback block, once it fires for THIS reason. + deals_headline_due_to_thin_listings = False if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N: listings_headline_thin_n = n_analogs - logger.info( - "headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing " - "listings-derived median (falling back to anchor/deals/insufficient_data)", - n_analogs, - HEADLINE_LISTINGS_MIN_N, + dkp_corridor_usable = ( + dkp_raw is not None + and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N + and dkp_raw.get("median_ppm2", 0) > 0 ) - median_ppm2 = 0.0 - q1_ppm2 = 0.0 - q3_ppm2 = 0.0 - median_price = 0 - range_low = 0 - range_high = 0 - n_analogs = 0 - cv = None + if dkp_corridor_usable: + gate_ceded_to_deals = True + logger.info( + "headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП " + "corridor (n=%s) available — suppressing listings-derived median, " + "ceding headline to deals/anchor chain", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + dkp_raw.get("count", 0) if dkp_raw else None, + ) + median_ppm2 = 0.0 + q1_ppm2 = 0.0 + q3_ppm2 = 0.0 + median_price = 0 + range_low = 0 + range_high = 0 + n_analogs = 0 + cv = None + else: + logger.info( + "headline sufficiency note #oblast-E: n=%d < %d listings, no usable " + "ДКП corridor — keeping real median, flagged low-reliability " + "(#oblast-F: never suppressed to zero without a fallback source)", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + ) - # 4b. Repair coefficient — skipped when the headline was thin-suppressed - # above (median_price is already 0; applying a coefficient would leave it - # 0 but still emit a misleading "adjusted for repair state" note). + # 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING + # thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is + # meaningful there. Skipped when the gate ceded the headline to deals + # (gate_ceded_to_deals — median_price is already 0 above; applying a + # coefficient would leave it 0 but still emit a misleading "adjusted for + # repair state" note, same reasoning original #oblast-E used). repair_coef = _repair_coefficient(repair_state) repair_note = "" - if listings_clean and not listings_headline_thin_n and repair_coef != 1.0: + if listings_clean and not gate_ceded_to_deals and repair_coef != 1.0: median_price = int(median_price * repair_coef) range_low = int(range_low * repair_coef) range_high = int(range_high * repair_coef) @@ -2613,19 +2814,20 @@ def _price_from_inputs( area_widened, listings=listings_clean, ) - # #oblast-E: honest override — _compute_confidence's generic "не найдено - # аналогов" is FALSE here (we DID find listings_headline_thin_n of them, - # just too few to trust). Stays the final explanation unless a later block - # (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest - # reasoning — both of those already check truthy `explanation` and either - # replace it (anchor) or append a construction-method clause that reads - # this same thin-count (deals-fallback), so no contradiction either way. - if listings_headline_thin_n: + # #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of + # the gate above (real thin median kept, no usable deals corridor to cede + # to). Case (i) (gate_ceded_to_deals) must NOT set this text — the + # deals-headline-fallback block below writes its OWN "built from + # Rosreestr deals" explanation; setting this first would leave a + # contradictory "Оценка построена по N аналогам" sentence stapled in front + # of it. Stays the final explanation unless a later block (anchor / + # #oblast-D deals-fallback) overwrites it with its OWN honest reasoning. + if listings_headline_thin_n and not gate_ceded_to_deals: confidence = "low" explanation = ( - f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., " - f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по " - "такой маленькой выборке слишком чувствительна к случайным лотам." + f"Оценка построена по {listings_headline_thin_n} " + f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, " + "точность снижена." ) # Tier note — информируем пользователя о качестве house-match @@ -3226,10 +3428,16 @@ def _price_from_inputs( n_analogs = 0 confidence = "low" cv = None - # #oblast-E: differentiate "genuinely zero listings" (unchanged wording) - # from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" — - # the latter must NOT claim "рядом нет объявлений" (false, contradicts the - # thin-sufficiency explanation already set above this block). + # #oblast-E (priority restored 2026-08-10): differentiate "genuinely + # zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N, + # ceded to the deals corridor" (gate above, case i) — the latter must + # NOT claim "рядом нет объявлений" (false — some WERE found, just not + # trusted as headline on their own). `deals_headline_due_to_thin_ + # listings` (returned on PricingResult) tells estimate_quality() this + # was the thin-cession path specifically, so it can (a) append the + # "оценка по сделкам — мало объявлений рядом" relaxation label, (b) + # cap reliability at 'low' — a real deals corridor is a real signal, + # just not a listings-comp one. no_listings_clause = ( f" Из {listings_headline_thin_n} найденных объявлений недостаточно для " "надёжной медианы —" @@ -3241,9 +3449,11 @@ def _price_from_inputs( f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.)," " точность ориентировочная." ) + if listings_headline_thin_n: + deals_headline_due_to_thin_listings = True logger.info( "deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline" - " (listings=0 [thin_suppressed=%d], anchor=None)", + " (listings=0 [thin_ceded=%d], anchor=None)", int(median_ppm2), dkp_raw["count"], listings_headline_thin_n, @@ -3379,12 +3589,18 @@ def _price_from_inputs( listings_clean=listings_clean, cv=cv, listings_headline_thin_n=listings_headline_thin_n, + deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings, ) # ── Public ─────────────────────────────────────────────────────────────────── async def estimate_quality( - payload: TradeInEstimateInput, db: Session, created_by: str | None = None + payload: TradeInEstimateInput, + db: Session, + created_by: str | None = None, + client_ip: str | None = None, + *, + require_consent: bool = False, ) -> AggregatedEstimate: """Главная функция — оценка квартиры по реальным данным. @@ -3395,9 +3611,34 @@ async def estimate_quality( AggregatedEstimate с tier classification (T0_per_house / T1_per_street) — frontend может разделять confidence в UI. + Args: + require_consent: ЭТАП 4 B2C consent-before-save gate (keyword-only, + дефолт False — см. _ESTIMATE_CONSENT_* комментарий выше про то, + почему это НЕ выводится из created_by is None). Единственный + реальный вызывающий — app/api/v1/trade_in.py::estimate() — передаёт + True, когда нет X-Authenticated-User (анонимный запрос). + Returns: AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами. + + Raises: + HTTPException(422): require_consent=True без payload.consent=True. + Проверка стоит ПЕРВОЙ строкой тела функции, ДО geocode() и ДО + обоих мест, где адрес попадает в trade_in_estimates (главный + INSERT ниже И _empty_estimate fallback) — адрес физлица не должен + попасть в БД раньше согласия ни при каком исходе оценки. """ + # ЭТАП 4 B2C launch (152-ФЗ) — consent-before-save. require_consent=False + # (дефолт, весь B2B-путь и все внутренние/тестовые вызовы) — эта проверка + # их не касается. require_consent=True (реально анонимный HTTP-запрос, + # см. Args выше) обязан нести явное согласие ПРЕЖДЕ, чем estimate_id будет + # сгенерирован и адрес уйдёт в БД. + if require_consent and not payload.consent: + raise HTTPException( + status_code=422, + detail="consent required for anonymous estimate request", + ) + # 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack # multiple network round-trips + 1s Nominatim rate-limit sleeps). geo: GeocodeResult | None = None @@ -3442,7 +3683,13 @@ async def estimate_quality( # Без координат не можем искать через PostGIS. Возвращаем low confidence. logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address) return await asyncio.to_thread( - _empty_estimate, payload, db, reason="address_not_geocoded", created_by=created_by + _empty_estimate, + payload, + db, + reason="address_not_geocoded", + created_by=created_by, + client_ip=client_ip, + require_consent=require_consent, ) # 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса. @@ -3604,6 +3851,9 @@ async def estimate_quality( house_type=target_house_type, total_floors=payload.total_floors, ) + # #oblast-F: True only when there WAS a cohort (year_built) filter to drop — + # surfaced later as the "снят фильтр по году постройки" relaxation label. + cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0 area_widened = False if len(listings) < 5: @@ -3652,6 +3902,146 @@ async def estimate_quality( analog_tier = analog_tier_wa search_radius_m = fallback_radius_m + # ── #oblast-F: relaxation cascade (never-block estimate, product decision + # 2026-08-10) ────────────────────────────────────────────────────────── + # Product requirement: NEVER refuse an estimate outright. If the 4-tier + # cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N, + # keep loosening search criteria — least → most distorting — until either + # the sample clears the threshold or we run out of steps. Every step that + # ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced + # via AggregatedEstimate.relaxations + appended to confidence_explanation + # below) so a low-reliability estimate honestly explains why it stretched + # the search. Each step carries FORWARD the relaxations already applied by + # earlier steps (cumulative widening), not just its own single criterion. + relaxations: list[str] = [] + if cohort_dropped: + relaxations.append("снят фильтр по году постройки") + + cur_rooms_min: int | None = None + cur_rooms_max: int | None = None + cur_fresh_days = LISTINGS_FRESH_DAYS + cur_allow_novostroyki = False + cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE + + async def _try_relax( + *, + rooms_min: int | None, + rooms_max: int | None, + fresh_days: int, + allow_novostroyki: bool, + radius_m: int, + area_tolerance: float, + ) -> tuple[list[dict[str, Any]], str] | None: + """Один шаг каскада #oblast-F. Возвращает (listings, tier) только если + кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана + (ничего реально не выиграла) и вызывающий её не применяет.""" + candidate, _, tier = await asyncio.to_thread( + _fetch_analogs, + db, + lat=geo.lat, + lon=geo.lon, + rooms=payload.rooms, + rooms_min=rooms_min, + rooms_max=rooms_max, + area=payload.area_m2, + radius_m=radius_m, + area_tolerance=area_tolerance, + fresh_days=fresh_days, + allow_novostroyki=allow_novostroyki, + full_address=geo.full_address, + target_house_id=target_house_id, + year_built=target_year, + house_type=target_house_type, + total_floors=payload.total_floors, + ) + if len(candidate) > len(listings): + return candidate, tier + return None + + # (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для + # rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0 + # тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA. + if len(listings) < HEADLINE_LISTINGS_MIN_N: + if payload.rooms <= 1: + try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии" + else: + try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA + try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA + rooms_label = "комнатность ±1" + rooms_result = await _try_relax( + rooms_min=try_rooms_min, + rooms_max=try_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=cur_allow_novostroyki, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if rooms_result is not None: + listings, analog_tier = rooms_result + cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max + relaxations.append(rooms_label) + + # (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60). + if len(listings) < HEADLINE_LISTINGS_MIN_N: + fresh_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=LISTINGS_FRESH_DAYS_RELAXED, + allow_novostroyki=cur_allow_novostroyki, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if fresh_result is not None: + listings, analog_tier = fresh_result + cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED + relaxations.append("объявления за 60 дней") + + # (c) снять guard listing_segment — допустить новостройки в comp-пул. + if len(listings) < HEADLINE_LISTINGS_MIN_N: + novo_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=True, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if novo_result is not None: + listings, analog_tier = novo_result + cur_allow_novostroyki = True + relaxations.append("учтены новостройки") + + # (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M. + # Пропускается, когда пользователь явно зафиксировал radius_m — тот же + # контракт, что и у существующего radius-fallback выше (#2044: сервер не + # авто-расширяет поиск за пределы выбранного пользователем радиуса). + if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None: + for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M): + if relax_radius <= search_radius_m: + continue + radius_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=cur_allow_novostroyki, + radius_m=relax_radius, + area_tolerance=cur_area_tolerance, + ) + if radius_result is not None: + listings, analog_tier = radius_result + search_radius_m = relax_radius + fallback_used = True + if len(listings) >= HEADLINE_LISTINGS_MIN_N: + break + + # Area/radius relaxations derived from FINAL state (covers both the + # pre-existing Tier B/C radius/area widening above AND step (d) here) — + # a single check avoids double-labelling the same underlying widening. + if area_widened: + relaxations.append("площадь ±25%") + if search_radius_m > base_radius_m: + relaxations.append(f"радиус расширен до {search_radius_m} м") + # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы # corridor_high был доступен для Tier C-гейта и soft-клампа headline. @@ -3864,7 +4254,73 @@ async def estimate_quality( ratio_basis = pr.ratio_basis listings_clean = pr.listings_clean cv = pr.cv - listings_headline_thin_n = pr.listings_headline_thin_n + + # #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals + # corridor because the base listings sample was thin — a real signal (real + # Rosreestr deals), just not a listings-comp one. Recorded as its own + # relaxation label (distinct from the #oblast-F cascade labels above, which + # describe attempts to grow the LISTINGS sample — this describes switching + # sources entirely). + if pr.deals_headline_due_to_thin_listings: + relaxations.append("оценка по сделкам — мало объявлений рядом") + + # #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/ + # deals-fallback override above) — independent of `confidence` (older + # unique-address/IQR metric, see AggregatedEstimate docstring). If the + # #oblast-F cascade had to relax anything to get here, an otherwise-"ok" + # sample is downgraded to "low" — the raw count looks fine, but it only + # exists because we widened the search past the user's exact criteria. + if n_analogs >= 8: + reliability: Literal["ok", "low", "very_low"] = "ok" + elif n_analogs >= 3: + reliability = "low" + else: + reliability = "very_low" + if pr.deals_headline_due_to_thin_listings: + # #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero — + # the headline came from deals instead), which would otherwise bucket + # to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is + # a real, meaningful signal — "не выше low" (product spec), not + # "почти нет сигнала" (what 'very_low' would imply here). + reliability = "low" + elif relaxations and reliability == "ok": + reliability = "low" + + # #conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11): + # `confidence` (unique-address/IQR metric, _compute_confidence above) and + # `reliability` (n_analogs-bucket + #oblast-F relaxation-cascade metric, + # just computed) used to be fully independent — the cascade's room- + # adjacency/freshness/novostroyki steps never set `fallback_used`, so + # _compute_confidence stayed blind to them and kept scoring a wide, + # tight-IQR (post-relaxation) sample as "high", while `reliability` + # honestly read "low" from the SAME relaxations. Live prod repro: n=39, + # confidence='high', reliability='low', 3 relaxations applied — client + # saw a "высокая уверенность" badge next to a "точность снижена" banner + # simultaneously. Fix applied at ONE point, after BOTH values are final + # (NOT spread across the cascade steps above) — see + # _cap_confidence_by_reliability docstring for the exact rule. + original_confidence = confidence + confidence = _cap_confidence_by_reliability(confidence, reliability) + confidence_downgraded_by_reliability = confidence != original_confidence + + if relaxations: + relaxations_detail = "Применены послабления подбора: " + ", ".join(relaxations) + "." + if confidence_downgraded_by_reliability: + # #conf-cap: explanation must LEAD with the accuracy-reduced summary + # when confidence was actually pulled down by the cap above — the + # old ordering opened with "Найдено 39 аналогов ... разброс ±6%" + # (reads as a confident, unqualified headline) and only tacked the + # relaxations sentence on at the very end, which itself contradicted + # the (now-capped) confidence badge just as much as the raw scores did. + explanation = ( + "Оценка построена с расширенными параметрами подбора — точность " + "снижена. " + (explanation or "") + " " + relaxations_detail + ) + else: + # No visible confidence change (e.g. it was already 'low'/'medium' + # pre-cap, or reliability=='ok' downgraded nothing) — keep the + # existing tail-append shape, unaffected by #conf-cap. + explanation = (explanation or "") + " " + relaxations_detail # 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals. # Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков @@ -3882,7 +4338,7 @@ async def estimate_quality( # 6. Сохраняем в trade_in_estimates estimate_id = uuid4() now = datetime.now(tz=UTC) - expires_at = now + timedelta(hours=24) + expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours) # #694: когда same-building якорь сработал, headline построен на комплах того # же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean @@ -3900,14 +4356,6 @@ async def estimate_quality( # иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому # набору (или = None при пустом listings_clean, хотя у комплов данные есть). metadata_lots = display_pool - elif listings_headline_thin_n: - # #oblast-E: headline was suppressed (thin radius sample, no anchor to - # take over) — do NOT surface those same listings as display cards - # either, else `analogs` would show N cards while n_analogs==0 (broken - # invariant, same dishonesty this gate exists to remove). Degrades to - # the exact same empty-display state as "genuinely zero listings". - analogs_lots = [] - metadata_lots = [] else: # display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что # дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs. @@ -3962,8 +4410,10 @@ async def estimate_quality( expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, + relaxations, reliability, created_by, - expires_at + expires_at, + consent, client_ip, consent_policy_version, consent_text_snapshot ) VALUES ( CAST(:id AS uuid), :address, :lat, :lon, @@ -3982,8 +4432,11 @@ async def estimate_quality( :expected_sold_price, :expected_sold_range_low, :expected_sold_range_high, :expected_sold_per_m2, :asking_to_sold_ratio, :ratio_basis, + CAST(:relaxations_json AS jsonb), :reliability, :created_by, - :expires_at + :expires_at, + :consent, CAST(:client_ip AS inet), :consent_policy_version, + :consent_text_snapshot ) """ ), @@ -4033,8 +4486,15 @@ async def estimate_quality( "expected_sold_per_m2": expected_sold_per_m2, "asking_to_sold_ratio": asking_to_sold_ratio, "ratio_basis": ratio_basis, + # #oblast-F / GET-rehydrate (PR #2823 open follow-up): персистим + # relaxations/reliability вместе с median — раньше эти поля жили + # только в POST-ответе, и красный баннер «точность снижена» + # пропадал при открытии оценки по сохранённой ссылке (?id=). + "relaxations_json": json.dumps(relaxations, ensure_ascii=False), + "reliability": reliability, "created_by": created_by, "expires_at": expires_at, + **_estimate_consent_persist_fields(require_consent, client_ip), }, ) @@ -4225,6 +4685,11 @@ async def estimate_quality( cv=cv, source_counts=source_counts, created_at=now, + # #oblast-F (never-block relaxation cascade) — применённые ослабления + # подбора + производная надёжность выборки (см. reliability computation + # above, независимо от `confidence`). + relaxations=relaxations, + reliability=reliability, ) @@ -4630,11 +5095,27 @@ def _extract_short_addr(full_address: str | None) -> str | None: # Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе. # Работает для FORWARD и REVERSE форматов Nominatim. +# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после +# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ +# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт +# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый +# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе +# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract +# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе. +# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` — +# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest- +# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+` +# после этого не находит пробел (следующий символ — "и"), движок +# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит +# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают +# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная +# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже +# покрывает оба варианта (с точкой и без). _STREET_KW_RE = re.compile( r"(? NOW() - (:fresh_days || ' days')::interval @@ -4826,8 +5310,13 @@ _COMMON_WHERE = """ -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает -- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента — - -- это вторичка или неклассифицированный объект). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki + -- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) — + -- дефолт False сохраняет канон-guard byte-identical. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled, -- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒ -- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает @@ -4882,6 +5371,14 @@ def _fetch_analogs( cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive target_house_id: int | None = None, # #6: canonical house for same-building Tier S + # #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты + # byte-identical старому поведению (exact rooms match / 14 дней / без + # новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО + # когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring. + rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms + rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms + fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED + allow_novostroyki: bool = False, # #oblast-F (c) ) -> tuple[list[dict[str, Any]], bool, str]: """SELECT аналогов — трёхуровневый house-match (S → H → W). @@ -4922,21 +5419,29 @@ def _fetch_analogs( """ area_min = area * (1 - area_tolerance) area_max = area * (1 + area_tolerance) + # #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms), + # byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a + # widened range only past HEADLINE_LISTINGS_MIN_N thinness. + eff_rooms_min = rooms if rooms_min is None else rooms_min + eff_rooms_max = rooms if rooms_max is None else rooms_max # #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL # (безвредно без фильтра); статический фрагмент управляет только применением # `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3 # bind-параметры не задействованы, инъекции нет. dup_filter = "AND rn_dup = 1" base_params: dict[str, Any] = { - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, "cohort_year_max": cohort_year_max, # #2012: is_apartments hard-filter — see _COMMON_WHERE comment above. "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, + # #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above. + "allow_novostroyki": allow_novostroyki, } # ── Tier S (canonical): same building via house_id_fk ───────────────────── @@ -5261,7 +5766,8 @@ def _fetch_analogs( FROM listings WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) AND (geo_precision IS DISTINCT FROM 'city') - AND rooms = :rooms + -- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же. + AND rooms BETWEEN :rooms_min AND :rooms_max AND area_m2 BETWEEN :area_min AND :area_max AND is_active = true AND scraped_at > NOW() - (:fresh_days || ' days')::interval @@ -5278,7 +5784,11 @@ def _fetch_analogs( ) -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий -- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению). AND ( @@ -5320,16 +5830,18 @@ def _fetch_analogs( "lat": lat, "lon": lon, "radius": radius_m, - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "target_year": year_built, "target_house_type": house_type, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, # NEW "cohort_year_max": cohort_year_max, # NEW "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012 + "allow_novostroyki": allow_novostroyki, # #oblast-F (c) }, ) .mappings() @@ -5804,11 +6316,6 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: return street, house -def _extract_street_token(addr: str | None) -> str: - """Нормализованный уличный токен для дедуп-ключа (#2265). См. _parse_street_house.""" - return _parse_street_house(addr)[0] - - def _lot_dedup_components( lot: dict[str, Any], *, @@ -5846,15 +6353,13 @@ def _lot_dedup_components( return cad_s, house, cad_key, street_key -def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None: - """Первичный физический ключ (building, floor, area_bucket, price_bucket). - - building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет - площади/цены или не из чего построить building. Сохраняет 4-кортежную форму - (canonical-ключ; union-find в _dedup_cross_source использует оба композита). - """ - _cad_s, _house, cad_key, street_key = _lot_dedup_components(lot) - return cad_key or street_key +# #2674: здесь жили `_phys_dedup_key` и `_extract_street_token` — однострочные обёртки +# над _lot_dedup_components / _parse_street_house. Прод не звал ни ту, ни другую ни разу +# (25 ссылок, все из тестов). Хуже: _phys_dedup_key утверждала правило «первичный ключ = +# кадастр ИЛИ улица», которого в проде нет — живой путь (_union_find_phys_dedup) держит +# ОБА композита и сливает по любому совпадению, с guard'ами на разные кадастры/номера +# домов. Тесты, проверявшие обёртку, проверяли не тот алгоритм; они переведены на живые +# функции (tests/test_estimator_dedup_cross_source_2087.py). def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]: @@ -6162,6 +6667,47 @@ def _downgrade_confidence(confidence: str) -> str: return {"high": "medium", "medium": "low", "low": "low"}.get(confidence, confidence) +_CONFIDENCE_ORDER: dict[str, int] = {"low": 0, "medium": 1, "high": 2} + + +def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str: + """#conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11). + + `confidence` (this function's sibling `_compute_confidence` — unique- + address/IQR metric) and `reliability` (estimate_quality — n_analogs-bucket + + #oblast-F relaxation-cascade metric) used to be computed fully + independently. The #oblast-F cascade's room-adjacency/freshness/ + novostroyki steps never set `fallback_used`, so `_compute_confidence` + stayed blind to them and could keep scoring a post-relaxation sample + (wide, tight-IQR) as "high" while `reliability` — which DOES see the + relaxations — honestly read "low". Live prod repro: n_analogs=39, + confidence='high', reliability='low', relaxations=[3 items] — a client + could see a "высокая уверенность" badge next to a "точность снижена" + banner on the SAME estimate. + + Rule (applied ONCE, by the caller, after both values are final — NOT + spread across the relaxation cascade): + reliability == 'very_low' → confidence forced to 'low'. + reliability == 'low' → confidence capped at 'medium' (high→medium; + medium/low pass through unchanged). + reliability == 'ok' → confidence untouched (unaffected — this is + the common, unrelaxed case; byte-identical + to pre-#conf-cap behaviour). + + Never RAISES confidence — only caps/lowers it. Unknown confidence/ + reliability values pass through unchanged (defensive, mirrors + _downgrade_confidence above). + """ + if reliability == "very_low": + return "low" + if reliability == "low": + current = _CONFIDENCE_ORDER.get(confidence) + cap = _CONFIDENCE_ORDER["medium"] + if current is not None and current > cap: + return "medium" + return confidence + + def _compute_confidence( n_analogs: int, median_ppm2: float, @@ -6353,16 +6899,27 @@ def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: def _empty_estimate( - payload: TradeInEstimateInput, db: Session, *, reason: str, created_by: str | None = None + payload: TradeInEstimateInput, + db: Session, + *, + reason: str, + created_by: str | None = None, + client_ip: str | None = None, + require_consent: bool = False, ) -> AggregatedEstimate: """Fallback когда нет данных для оценки. Сохраняет запись в БД (confidence='low', пустые analogs/deals), чтобы GET /estimate/{id} не возвращал 404. C-4 security audit. + + ЭТАП 4 B2C: этот путь тоже пишет адрес в trade_in_estimates -- consent-gate в + estimate_quality() уже отработал ДО вызова (require_consent=True подразумевает + payload.consent is True), здесь просто персистим то же consent-доказательство, + что и главный путь (_estimate_consent_persist_fields). """ estimate_id = uuid4() now = datetime.now(tz=UTC) - expires_at = now + timedelta(hours=24) + expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours) db.execute( text( @@ -6376,8 +6933,10 @@ def _empty_estimate( confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, + relaxations, reliability, created_by, - expires_at + expires_at, + consent, client_ip, consent_policy_version, consent_text_snapshot ) VALUES ( CAST(:id AS uuid), :address, :area, :rooms, :floor, :total_floors, @@ -6387,8 +6946,15 @@ def _empty_estimate( 'low', :explanation, 0, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, + -- #oblast-F: поиск аналогов вообще не выполнялся (geocode failed / + -- no coords) — каскад послаблений не запускался, relaxations честно + -- пуст; reliability='very_low' зеркалит то, что возвращает Python + -- ниже (см. AggregatedEstimate(..., reliability="very_low")). + '[]'::jsonb, 'very_low', :created_by, - :expires_at + :expires_at, + :consent, CAST(:client_ip AS inet), :consent_policy_version, + :consent_text_snapshot ) """ ), @@ -6408,6 +6974,7 @@ def _empty_estimate( "explanation": reason, "created_by": created_by, "expires_at": expires_at, + **_estimate_consent_persist_fields(require_consent, client_ip), }, ) db.commit() @@ -6432,4 +6999,7 @@ def _empty_estimate( # Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна. address_precision=None, analog_tier=None, # нет данных при empty estimate + # #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся + # (geocode failed / no coords), а не просто "мало нашлось". + reliability="very_low", ) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 425dda5f..a89eadf6 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price from matplotlib.patches import Rectangle from app.core.config import settings +from app.core.version import product_version_line from app.schemas.trade_in import AggregatedEstimate, AnalogLot logger = logging.getLogger(__name__) @@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом) _BORDER = _LINE _BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline) -# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит -# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не -# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь -# HUD, а не версия PDF-модуля/API. -_REPORT_ENGINE_VERSION = "v2.0.6" - # Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/ # 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах. _FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions @@ -505,13 +500,31 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type: "ДАТА", report_date.strftime("%d.%m.%Y") ) + # Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение + # владельца продукта 2026-08-10, см. app/core/version.py::product_version_line. + # Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска + # реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера», + # одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под + # существующим masthead-рядом — не растёт по высоте ни при каком контенте + # (клипается по ширине, не переносится), top-margin (25mm) даёт под неё + # запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты. + version_html = ( + f'
' + f"{_html.escape(product_version_line())}
" + ) + return ( + f"
" f'
' + f'padding-bottom:6pt;margin-bottom:3pt;">' f"{mark_html}" f'{meta_html}' f"
" + f"{version_html}" + f"
" ) @@ -529,7 +542,11 @@ def _page_footer( строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ - хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта. + хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь + НЕ дублируется — единственное место вывода версии в PDF — running-header + (_page_header → product_version_line()); раньше рядом с wordmark висел + decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной + версией продукта — расходился с header на каждой странице, снесён. page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер на офер-странице), которого нет в веб-референсе (там нет пагинации). Не @@ -587,9 +604,6 @@ def _page_footer( font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2}; min-width:0;overflow-wrap:anywhere;"> {_html.escape(brand.name).upper()} - - {_REPORT_ENGINE_VERSION}
@@ -1050,6 +1064,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s ) report_num = _report_number(estimate) + # PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа + # (retain_until), НЕ путать со «Срок действия данных» (expires_at, + # актуальность расчёта) над ней — эта строка не трогается. Рендерится + # ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий + # трафик — не видят этой строки вообще, поведение бит-в-бит текущее). + retain_until_row = ( + f'Ссылка доступна до' + f'' + f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}" + if estimate.retain_until is not None + else "" + ) + # Короткий адрес (для cover): берём первую часть до запятой full_address = input_snapshot.get("address", "—") address_short = full_address.split(",")[0:3] @@ -1146,6 +1173,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s {_mono(today.strftime("%d.%m.%Y"))} Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))} + {retain_until_row} Адрес{address} Год постройки {year_label} @@ -1229,11 +1257,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int return min(prices), max(prices) +def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str: + """#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest + footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals + corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT + estimate.analogs is non-empty (the thin listings that triggered the cession + are still shown below as reference cards — never cleared, see estimator.py + #1871 ghost-anchor guard). Same tone/plain-sentence style as the web + LowConfidenceBanner for this scenario. Empty string (no-op) otherwise — + covers both "healthy sample" and "genuinely zero, nothing to show" cases.""" + if estimate.n_analogs != 0 or not estimate.analogs: + return "" + return ( + f'

' + "Оценка построена по зарегистрированным сделкам Росреестра — подходящих " + "объявлений поблизости почти нет. Объявления ниже приведены справочно, " + "для наглядности рынка.

" + ) + + +def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str: + """#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability` + (estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report + already shows this (LowConfidenceBanner); the PDF stayed silent, a + client-visible discrepancy between the two. Empty string (no-op) when + reliability=='ok' and relaxations is empty — the common, unrelaxed case, + byte-identical to the report before these fields existed.""" + if estimate.reliability == "ok" and not estimate.relaxations: + return "" + if estimate.relaxations: + detail = "Подбор аналогов расширен: " + ", ".join( + _html.escape(r) for r in estimate.relaxations + ) + else: + # relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка, + # которую каскад ослаблений не смог расширить, см. estimator.py + # #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика, + # что и в счётчике выше страницы). + detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)" + return f""" +
+ Точность оценки снижена. + {detail} — данные ниже приведены с этой оговоркой. +
+""" + + # ── Page 2: Listings (market) ──────────────────────────────────────────────── def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] - n_total = estimate.n_analogs + # #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw + # estimate.n_analogs is the count of listings that drove the HEADLINE math — + # it is deliberately 0 when the headline was ceded to the ДКП deals corridor + # (estimator.py `deals_headline_due_to_thin_listings`), even though the thin + # listings that triggered that cession are still shown below as display cards + # (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor + # guard comment). Printing raw n_analogs there read as "0 шт." above a + # non-empty examples table — a client-visible contradiction. n_analogs is + # normally >= len(analogs) (analogs is a top-10-capped SUBSET of what + # n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a + # no-op in that common case (count stays the honest FULL n_analogs) and only + # changes anything in this one pathological case, where it falls back to + # "how many are actually shown" instead of the dishonest zero. + n_total = max(estimate.n_analogs, len(estimate.analogs)) # #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует # аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160), # а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного @@ -1292,6 +1380,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra examples_rows = _examples_rows(top5) heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ") + # #pdf-honesty — see helper docstrings above. Both no-op ("") in the common + # (unrelaxed, non-deals-sourced) case — byte-identical page in that case. + deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate) + reliability_note = _reliability_note_html(estimate, n_total) return f"""
@@ -1306,6 +1398,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra Количество объявлений по аналогичным объектам {_mono(f"{n_total} шт.")} + {deals_sourced_note}
Источники данных
{sources_html}
@@ -1330,6 +1423,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra + {reliability_note}

Диапазон цен в объявлениях

diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index ede3356b..511386a1 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -13,8 +13,10 @@ WHAT this is: pipeline, run inside ONE transaction so a crash leaves the table untouched. Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address - COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod — - confirmed in migration 040 — so address is the real building key). The clean source matters: + COALESCE(short_address, full_address, address) — the address is the only building key we + have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on + prod», is no longer true — 2 648 of 9 179 rows carry one — and the conclusion no longer + rests on it). The clean source matters: `address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр» → canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66» (→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon @@ -92,6 +94,67 @@ BACKFILL (reduces recurrence): (same as 108) so the matching pipeline's Tier-1/Tier-2 finds the keeper next scrape and does not immediately re-split it. +MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230): + Every loser gets a row in `house_merge_log` written in the SAME transaction as the merge: + the full jsonb snapshot of the deleted row, the keeper's snapshot BEFORE the identity + carry-over, the ids of every child row whose FK moved, the full snapshots of every child row + a UNIQUE collision destroyed, plus the grounds — which pass, which cluster-key VALUE fired, + whether the geo guard was on, and the keeper↔loser distance in metres. + + This exists because the merge used to leave no restorable trace: losers were hard-deleted + with their children and the only record of «what went into what» was a log line, in a + container whose logs rotate faster than a day. A day after a run nobody could even NAME the + pairs, and the only rollback was restoring the whole database. + + Undo: `SELECT * FROM house_merge_undo(batch_id)` inside a transaction — restores the loser + rows, points the children back, re-inserts the destroyed children, and un-does the identity + carry-over on the keeper, reporting per record what it could and could not restore. + + NOTE the journal is deliberately NEUTRAL to the merge rule: it changes no cluster key, no + keeper rule and no guard. It only makes whatever the pass decides reversible — which is the + precondition for revisiting those decisions at all (#2690, #1772). + + distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That + asymmetry — merge allowed without a proximity check — was invisible in data before; now + «how many merges happened beyond N metres, on which key» is one query. + +KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690): + #2690 asked for a cluster key that does not come from the normalized address, so that two + rows merge on two independent statements of identity rather than one restated twice. Every + field `houses` carries was checked against the live table. None qualifies: + + cadastral_number 2 648 filled, ALL 2 648 values DISTINCT → collapses nothing. Provenance: + all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are + DaData's answer to our address string, not a second observation of the + building. (The other cadastre we hold, listings.building_cadastral_number, + is the KNN geo-nearest hint — 20.1% of its values cover >1 ГАР building; + #2674 refused it as an identity key and that stands.) + house_fias_id 3 678 filled, ALL DISTINCT → the FIAS pass merges 0 rows today. Same + DaData provenance. + gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share + the canon (the guid restates it), 17 do not — and 5 of those 17 are + >250 m apart, worst 5 064 km. Still circular, still noisy. + zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the + loader sets it WHERE gar_house_guid = , i.e. it IS the ГАР guid for + 4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback + — keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon, + 193 come through the fallback, and 30 of the 31 pairs >250 m apart do too. + source+ext_house_id, cian_internal_house_id, yandex_jk_id + distinct by construction / 39 / 0 rows → nothing to cluster. + coordinates a real independent observation, but not an IDENTITY: neighbours share a + yard. It is already used the only way it can be — as the guard. + year_built+total_floors + a FALSE witness, not a corroborator: of the 391 same-canon pairs the + guard cannot judge, only 18 agree on both fields (357 have a NULL), while + 306 pairs the guard rejected at >250 m DO agree — it would confirm merges + that are provably wrong. + + Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the + canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the + residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the + guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart + (median 1 084 m) — those are not duplicates at all, the canon key is wrong about them. + IDEMPOTENCY: Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the mapping is empty → every statement touches 0 rows → no-op. Re-running is safe. @@ -105,8 +168,10 @@ psycopg v3: all SQL uses CAST(:x AS type), never the colon-colon bound-param cas from __future__ import annotations +import json import logging import time +import uuid from dataclasses import dataclass, field from typing import Any @@ -132,9 +197,24 @@ _COMPLETENESS_EXPR = """ # Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they # agree row-for-row. Priority: geom present → most linked listings → most-populated → min id. +# +# NULLS LAST на listing_cnt (#2674): счётчик приходит из LEFT JOIN listing_counts, поэтому у дома +# БЕЗ объявлений он NULL, а `DESC` в Postgres по умолчанию NULLS FIRST — то есть строка с нулём +# объявлений обгоняла строку со 192 и забирала роль keeper'а, ровно наоборот задокументированному +# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую +# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома / +# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно. +# +# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле — +# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина +# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния). +# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого +# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить +# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом +# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей». _KEEPER_ORDER = f""" (h.geom IS NOT NULL) DESC, - listing_cnt DESC, + listing_cnt DESC NULLS LAST, ({_COMPLETENESS_EXPR}) DESC, h.id ASC """ @@ -166,37 +246,17 @@ _CANON_KEY_EXPR = """ """ -def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: - """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. +def _ranked_cte(cluster_key_case: str) -> str: + """Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row. - Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers - are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered - row shares one fias by construction). - - apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. - - CANON pass → True: the canon strips город/район, so same-street-number buildings in - different region-66 towns share a canon; the guard stops the cross-town over-merge. - - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks - proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the - geom-first keeper rule simultaneously repairs the broken coordinate). + Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census + (`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census + built from its own copy of the clustering would drift from the pass it describes and the + drift would be invisible (it is the same class of error as #2690's cluster key: two + expressions that look alike and are not). `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection. """ - geo_guard = ( - """ - -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two - -- different buildings sharing a street+number canon («Ленина 5» in different region-66 - -- towns) collapse to one cluster_key. A loser merges only when geographically next to the - -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at - -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL - -- geom on either side, → left as separate rows (conservative — never over-merges). - AND keeper_geom IS NOT NULL - AND loser_geom IS NOT NULL - AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250""" - if apply_geo_guard - else "" - ) return f""" - CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS WITH clustered AS ( SELECT id, @@ -248,12 +308,55 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: FROM dup_houses dh JOIN houses h ON h.id = dh.id LEFT JOIN listing_counts lc ON lc.house_id = dh.id + )""" + + +def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: + """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. + + Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers + are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered + row shares one fias by construction). + + apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. + - CANON pass → True: the canon strips город/район, so same-street-number buildings in + different region-66 towns share a canon; the guard stops the cross-town over-merge. + - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks + proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the + geom-first keeper rule simultaneously repairs the broken coordinate). + `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection. + """ + geo_guard = ( + """ + -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two + -- different buildings sharing a street+number canon («Ленина 5» in different region-66 + -- towns) collapse to one cluster_key. A loser merges only when geographically next to the + -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at + -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL + -- geom on either side, → left as separate rows (conservative — never over-merges). + AND keeper_geom IS NOT NULL + AND loser_geom IS NOT NULL + AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250""" + if apply_geo_guard + else "" ) + return f""" + CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS + {_ranked_cte(cluster_key_case)} -- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but -- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon -- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per -- cluster) and for canon clusters where at most one side carries a fias. - SELECT id AS loser_id, keeper_id, norm_address + -- + -- cluster_key / distance_m are carried out of the mapping for the MERGE JOURNAL (#2690): + -- cluster_key records WHICH key value fired, distance_m how far apart the two rows were. + -- distance_m is computed even when the geo guard is OFF for this pass — that is precisely + -- the case where nothing else records the distance, and #2690 had no way to ask + -- «how many merges happened at distances the guard would have blocked» from data. + SELECT id AS loser_id, keeper_id, norm_address, cluster_key, + CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL + THEN ST_DistanceSphere(loser_geom, keeper_geom) + END AS distance_m FROM ranked WHERE rn > 1 AND id <> keeper_id{geo_guard} @@ -272,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR)) # merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords). _BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False)) +# ── RESIDUAL CENSUS (#2690 п.2/п.4) ─────────────────────────────────────────── +# +# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY. +# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered +# out is counted here, bucketed by the reason it survived. +# +# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there +# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a +# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the +# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821. +# +# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard +# rejected» are opposite facts: +# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN. +# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are +# NOT duplicates — the canon key is wrong about them (prod 2026-08-10: +# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt. +# residual_cross_fias — provably different buildings (two different ФИАС UUIDs). +# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real +# run; non-zero is a tripwire on the pass itself, not a census entry. +# residual_listings is the user-visible size of the remainder (listings hanging on those rows). +_RESIDUAL_SQL = text( + f""" + {_ranked_cte(_CANON_KEY_EXPR)} + SELECT + count(*) FILTER (WHERE rn > 1) AS residual_rows, + COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings, + count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL) + AS residual_no_geom, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250) + AS residual_mergeable + FROM ( + SELECT rn, + COALESCE(lc.listing_cnt, 0) AS lcnt, + CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL + THEN ST_DistanceSphere(loser_geom, keeper_geom) + END AS dist, + (NULLIF(loser_fias, '') IS NOT NULL + AND NULLIF(keeper_fias, '') IS NOT NULL + AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias + FROM ranked + LEFT JOIN listing_counts lc ON lc.house_id = ranked.id + ) r + """ +) + # Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op. _STEPS: list[tuple[str, str]] = [ # ── Plain re-point (no UNIQUE on the FK column) ─────────────────────────── @@ -282,6 +433,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id_fk = m.keeper_id FROM _1772_dup_mapping m WHERE l.house_id_fk = m.loser_id + RETURNING m.loser_id, l.id AS child_id """, ), ( @@ -291,6 +443,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hph.house_id = m.loser_id + RETURNING m.loser_id, hph.id AS child_id """, ), ( @@ -300,6 +453,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hr.house_id = m.loser_id + RETURNING m.loser_id, hr.id AS child_id """, ), ( @@ -309,6 +463,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hrc.house_id = m.loser_id + RETURNING m.loser_id, hrc.id AS child_id """, ), ( @@ -318,6 +473,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE ev.house_id = m.loser_id + RETURNING m.loser_id, ev.id AS child_id """, ), # ── UNIQUE(ext_source, ext_id): delete colliding losers, re-point rest ───── @@ -333,6 +489,7 @@ _STEPS: list[tuple[str, str]] = [ AND hs2.ext_source = hs.ext_source AND hs2.ext_id = hs.ext_id ) + RETURNING hs.house_id AS loser_id, to_jsonb(hs.*) AS row_snapshot """, ), ( @@ -342,6 +499,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hs.house_id = m.loser_id + RETURNING m.loser_id, hs.id AS child_id """, ), # ── UNIQUE(normalized_address): delete colliding losers, re-point rest ───── @@ -356,6 +514,7 @@ _STEPS: list[tuple[str, str]] = [ WHERE haa2.house_id = m.keeper_id AND haa2.normalized_address = haa.normalized_address ) + RETURNING haa.house_id AS loser_id, to_jsonb(haa.*) AS row_snapshot """, ), ( @@ -365,6 +524,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE haa.house_id = m.loser_id + RETURNING m.loser_id, haa.id AS child_id """, ), # ── UNIQUE(house_id, source, room_count, prices_type, period, month_date) ── @@ -386,6 +546,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -395,6 +556,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hpd.house_id = m.loser_id + RETURNING m.loser_id, hpd.id AS child_id """, ), # ── UNIQUE(house_id): one evaluation per keeper ─────────────────────────── @@ -412,6 +574,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -421,6 +584,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hie.house_id = m.loser_id + RETURNING m.loser_id, hie.id AS child_id """, ), # ── UNIQUE(house_id, ext_item_id) ───────────────────────────────────────── @@ -438,6 +602,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -447,6 +612,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hs.house_id = m.loser_id + RETURNING m.loser_id, hs.id AS child_id """, ), # ── UNIQUE(house_id, audit_batch) ───────────────────────────────────────── @@ -464,6 +630,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -473,10 +640,98 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE ama.house_id = m.loser_id + RETURNING m.loser_id, ama.id AS child_id """, ), ] +# ── MERGE JOURNAL (#2690) ───────────────────────────────────────────────────── +# +# Every child of houses(id) except `listings` references it through a column named house_id; +# listings uses house_id_fk. The undo function reads the column name back out of the journal +# key ("таблица.колонка"), so this mapping is what makes the reverse UPDATE possible. +_FK_COLUMN = {"listings": "house_id_fk"} + +# The (table, column) pairs the _STEPS pipeline actually handles, derived FROM the steps so the +# set cannot drift away from them. Compared against pg_catalog before every merge — see +# _assert_all_fk_children_handled. +_HANDLED_CHILDREN: frozenset[tuple[str, str]] = frozenset( + (tbl, _FK_COLUMN.get(tbl, "house_id")) for tbl in {label.split("(")[0] for label, _ in _STEPS} +) + +# Live FK children of houses(id), read from the catalog rather than trusted from a comment. +_FK_CHILDREN_SQL = text( + """ + SELECT CAST(CAST(c.conrelid AS regclass) AS text) AS child_table, + a.attname AS fk_column + FROM pg_constraint c + JOIN unnest(c.conkey) AS k(attnum) ON true + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.confrelid = CAST('houses' AS regclass) + AND c.contype = 'f' + """ +) + +# One journal row per loser, written from the mapping BEFORE anything is mutated — so loser_row +# is the row as it stood, and keeper_before precedes the identity carry-over. +_JOURNAL_INSERT_SQL = text( + """ + INSERT INTO house_merge_log ( + batch_id, run_id, initiator, merge_pass, cluster_key, geo_guard, distance_m, + norm_address, loser_id, keeper_id, loser_row, keeper_before + ) + SELECT + CAST(:batch_id AS uuid), + CAST(:run_id AS bigint), + CAST(:initiator AS text), + CAST(:merge_pass AS text), + m.cluster_key, + CAST(:geo_guard AS boolean), + m.distance_m, + m.norm_address, + m.loser_id, + m.keeper_id, + to_jsonb(l.*), + to_jsonb(k.*) + FROM _1772_dup_mapping m + JOIN houses l ON l.id = m.loser_id + JOIN houses k ON k.id = m.keeper_id + """ +) + +# Child bookkeeping lands after the steps ran — only then is it known which rows moved and which +# were destroyed by a UNIQUE collision. +_JOURNAL_CHILDREN_SQL = text( + """ + UPDATE house_merge_log + SET children_repointed = CAST(:children_repointed AS jsonb), + children_deleted = CAST(:children_deleted AS jsonb) + WHERE batch_id = CAST(:batch_id AS uuid) + AND loser_id = CAST(:loser_id AS bigint) + """ +) + + +def _assert_all_fk_children_handled(db: Session) -> None: + """Fail the merge if houses(id) gained an FK child the _STEPS pipeline does not handle. + + This is what makes the journal's promise true rather than merely documented. An unhandled + child is not a cosmetic gap: 9 of the 11 FKs are ON DELETE CASCADE, so `DELETE FROM houses` + would destroy its rows silently — no re-point step touches them, no RETURNING records them, + and the journal would claim a complete snapshot it does not have. Migration 133 already + broke on prod for exactly this (a missed child); there the failure was loud. Here it would + be silent, which is worse. Aborting the transaction costs one skipped merge cycle. + """ + live = {(r.child_table, r.fk_column) for r in db.execute(_FK_CHILDREN_SQL).all()} + unhandled = live - _HANDLED_CHILDREN + if unhandled: + raise RuntimeError( + "merge_duplicate_houses: houses(id) has FK children the merge does not handle: " + f"{sorted(unhandled)}. Their rows would be CASCADE-deleted without a journal entry. " + "Add a re-point step to _STEPS (and its RETURNING) before merging again." + ) + + # Delete the loser houses — all FK children are re-pointed or CASCADE by now. _DELETE_LOSERS_SQL = text( """ @@ -580,6 +835,15 @@ class DedupMergeResult: listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children children_repointed: int = 0 # survivor child rows moved loser→keeper + # Residual census (#2690): same-canon rows STILL in the table after this run, by reason. + # Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with, + # so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL. + residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets) + residual_listings: int = 0 # listings hanging on them (the user-visible size) + residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates + residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building + residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings + residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0 dry_run: bool = False duration_sec: float = field(default=0.0) @@ -590,6 +854,12 @@ class DedupMergeResult: "listings_repointed": self.listings_repointed, "children_deleted": self.children_deleted, "children_repointed": self.children_repointed, + "residual_rows": self.residual_rows, + "residual_listings": self.residual_listings, + "residual_no_geom": self.residual_no_geom, + "residual_far": self.residual_far, + "residual_cross_fias": self.residual_cross_fias, + "residual_mergeable": self.residual_mergeable, "dry_run": int(self.dry_run), "duration_sec": int(self.duration_sec), } @@ -600,14 +870,20 @@ def _run_merge_pass( *, build_sql: Any, pass_label: str, + geo_guard: bool, + batch_id: str, + run_id: int | None, + initiator: str, result: DedupMergeResult, ) -> None: """Run ONE merge pass (fias- or canon-key) inside the caller's open transaction. - Builds a fresh loser→keeper mapping for this pass's cluster key, re-points every FK child - (UNIQUE-collision-safe), carries identity/enrichment onto the keeper, deletes the losers and - backfills sources/aliases. Accumulates counters onto `result`. NEVER commits/rolls back — the - caller owns the single transaction wrapping both passes. + Builds a fresh loser→keeper mapping for this pass's cluster key, writes the MERGE JOURNAL + (#2690), re-points every FK child (UNIQUE-collision-safe), carries identity/enrichment onto + the keeper, deletes the losers and backfills sources/aliases. Accumulates counters onto + `result`. NEVER commits/rolls back — the caller owns the single transaction wrapping both + passes, which is also what makes the journal atomic with the merge: there is no ordering in + which the rows vanish but the journal entry does not land (and dry_run rolls back both). """ # Fresh mapping for this pass. ON COMMIT DROP only fires at txn end, so drop the temp table # explicitly — the second pass must rebuild the same-named table within the one transaction. @@ -616,8 +892,8 @@ def _run_merge_pass( mapping = db.execute( text( - "SELECT loser_id, keeper_id, norm_address FROM _1772_dup_mapping " - "ORDER BY keeper_id, loser_id" + "SELECT loser_id, keeper_id, norm_address, cluster_key, distance_m " + "FROM _1772_dup_mapping ORDER BY keeper_id, loser_id" ) ).all() if not mapping: @@ -627,32 +903,73 @@ def _run_merge_pass( result.losers_deleted += len(mapping) result.clusters_merged += len({row.keeper_id for row in mapping}) - # Audit log: every loser→keeper move with its address, for traceability. + # JOURNAL, phase 1 — snapshot loser + keeper BEFORE any statement mutates them. + db.execute( + _JOURNAL_INSERT_SQL, + { + "batch_id": batch_id, + "run_id": run_id, + "initiator": initiator, + "merge_pass": pass_label, + "geo_guard": geo_guard, + }, + ) + + # Container logs rotate faster than a day (#2690), so this line is a convenience, not the + # record — house_merge_log is. Distance is logged too: it is the one number that says + # whether a merge would have survived the geo guard. for row in mapping: logger.info( - "merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r", + "merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r " + "distance_m=%s batch=%s", pass_label, row.loser_id, row.keeper_id, row.norm_address, + "n/a" if row.distance_m is None else f"{row.distance_m:.0f}", + batch_id, ) + # Per-loser child bookkeeping, collected from each step's RETURNING: survivors by id (the + # rows are intact, only their FK moved), destroyed rows by full snapshot (nothing else is + # left of them). + repointed: dict[int, dict[str, list[int]]] = {} + deleted: dict[int, dict[str, list[Any]]] = {} + for label, sql in _STEPS: - res = db.execute(text(sql)) - rowcount = res.rowcount or 0 - if label == "listings": - result.listings_repointed += rowcount - elif label.endswith("(collision-delete)") or label.endswith("(dedup)"): + rows = db.execute(text(sql)).all() + rowcount = len(rows) + table = label.split("(")[0] + if label.endswith("(collision-delete)") or label.endswith("(dedup)"): result.children_deleted += rowcount - elif label.endswith("(re-point)") or label in ( - "house_placement_history", - "house_reviews", - "house_reliability_checks", - "external_valuations", - ): - result.children_repointed += rowcount + for r in rows: + deleted.setdefault(r.loser_id, {}).setdefault(table, []).append(r.row_snapshot) + else: + key = f"{table}.{_FK_COLUMN.get(table, 'house_id')}" + for r in rows: + repointed.setdefault(r.loser_id, {}).setdefault(key, []).append(r.child_id) + if label == "listings": + result.listings_repointed += rowcount + else: + result.children_repointed += rowcount logger.debug("merge_duplicate_houses: pass=%s step=%s rows=%d", pass_label, label, rowcount) + # JOURNAL, phase 2 — attach the child bookkeeping to the rows written in phase 1. + touched = sorted(set(repointed) | set(deleted)) + if touched: + db.execute( + _JOURNAL_CHILDREN_SQL, + [ + { + "batch_id": batch_id, + "loser_id": loser_id, + "children_repointed": json.dumps(repointed.get(loser_id, {})), + "children_deleted": json.dumps(deleted.get(loser_id, {}), default=str), + } + for loser_id in touched + ], + ) + # Carry identity/enrichment onto the keeper BEFORE the losers vanish, then delete + backfill. db.execute(_CARRY_OVER_IDENTITY_SQL) db.execute(_DELETE_LOSERS_SQL) @@ -660,7 +977,56 @@ def _run_merge_pass( db.execute(_BACKFILL_ALIASES_SQL) -def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]: +def _measure_residual(db: Session, result: DedupMergeResult) -> None: + """Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690). + + Read-only; runs after both passes, so it describes the table as the run leaves it (under + dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of + `_run_merge_pass` because the census is about the CANON key only and must be taken once per + call, not once per pass. + + Never fails the merge: the merge itself is the product, the census is instrumentation, and a + census that can abort a committed-by-now transaction would be worse than a missing number. + """ + try: + rows = db.execute(_RESIDUAL_SQL).all() + except Exception: + logger.exception("merge_duplicate_houses: residual census failed — counters left at 0") + return + if not rows: + return + r = rows[0] + result.residual_rows = int(r.residual_rows or 0) + result.residual_listings = int(r.residual_listings or 0) + result.residual_no_geom = int(r.residual_no_geom or 0) + result.residual_far = int(r.residual_far or 0) + result.residual_cross_fias = int(r.residual_cross_fias or 0) + result.residual_mergeable = int(r.residual_mergeable or 0) + logger.info( + "merge_duplicate_houses: residual rows=%d listings=%d " + "(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)", + result.residual_rows, + result.residual_listings, + result.residual_no_geom, + result.residual_far, + result.residual_cross_fias, + result.residual_mergeable, + ) + if result.residual_mergeable: + logger.warning( + "merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass " + "left work on the table (expected 0)", + result.residual_mergeable, + ) + + +def merge_duplicate_houses( + db: Session, + *, + dry_run: bool = False, + run_id: int | None = None, + initiator: str = "manual", +) -> dict[str, int]: """Cluster houses by fias UUID, then by canonical address, merging dups onto one keeper. Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job: @@ -673,16 +1039,45 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an empty mapping in each pass → every statement is a 0-row no-op. + Every deleted row is journaled to house_merge_log in the SAME transaction (#2690), so a + merge is reversible via house_merge_undo(batch_id); the batch_id is returned in the log line + and stored on every journal row of this call. + Returns the counter dict (DedupMergeResult.to_counters()). """ start = time.monotonic() result = DedupMergeResult(dry_run=dry_run) + batch_id = str(uuid.uuid4()) try: + # Refuse to merge at all if some FK child would be CASCADE-destroyed unjournaled. + _assert_all_fk_children_handled(db) # Pass 1: cluster by the ФИАС building UUID (runs first — most precise building identity). - _run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL_FIAS, pass_label="fias", result=result) + _run_merge_pass( + db, + build_sql=_BUILD_MAPPING_SQL_FIAS, + pass_label="fias", + geo_guard=False, + batch_id=batch_id, + run_id=run_id, + initiator=initiator, + result=result, + ) # Pass 2: cluster by canonical address, with the cross-fias anti-over-merge guard. - _run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL, pass_label="canon", result=result) + _run_merge_pass( + db, + build_sql=_BUILD_MAPPING_SQL, + pass_label="canon", + geo_guard=True, + batch_id=batch_id, + run_id=run_id, + initiator=initiator, + result=result, + ) + + # Census of what is LEFT (read-only). Runs before the no-op early return on purpose: + # a run that merged nothing is exactly the run whose remainder needs a number. + _measure_residual(db, result) if result.losers_deleted == 0: # Clean table — both passes empty. Roll back (we only opened temp tables). @@ -709,12 +1104,15 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i db.commit() logger.info( "merge_duplicate_houses: COMMITTED clusters=%d losers=%d " - "listings_repointed=%d children_deleted=%d children_repointed=%d", + "listings_repointed=%d children_deleted=%d children_repointed=%d " + "batch_id=%s (undo: SELECT * FROM house_merge_undo('%s'))", result.clusters_merged, result.losers_deleted, result.listings_repointed, result.children_deleted, result.children_repointed, + batch_id, + batch_id, ) except Exception: logger.exception("merge_duplicate_houses: FAILED — rolling back") @@ -754,7 +1152,7 @@ def run_house_dedup_merge(db: Session, *, run_id: int, params: dict) -> dict[str } try: runs_mod.update_heartbeat(db, run_id, counters) - counters = merge_duplicate_houses(db, dry_run=dry_run) + counters = merge_duplicate_houses(db, dry_run=dry_run, run_id=run_id, initiator="schedule") runs_mod.mark_done(db, run_id, counters) logger.info( "run_house_dedup_merge: run_id=%d DONE clusters=%d losers=%d dry_run=%s", diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index 38bdd005..edb02137 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -30,6 +30,7 @@ from dataclasses import dataclass, field from typing import Literal from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.house_type_normalizer import normalize_house_type # #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска, # что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex @@ -65,22 +66,76 @@ _HEARTBEAT_EVERY_N_HOUSES = 5 # ── house_type normalisation ───────────────────────────────────────────────── +# Ключи — КАНОНИЧНЫЕ значения listings.house_type (после normalize_house_type), +# значения — вокабуляр Avito IMV. _HOUSE_TYPE_TO_IMV: dict[str, str] = { "panel": "panel", "brick": "brick", "monolith": "monolithic", - "monolithic": "monolithic", "monolith_brick": "monolithic", # Avito API не принимает гибриды "block": "block", "wood": "wood", } -_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ -def _map_house_type(raw: str | None) -> str: - if not raw: - return _HOUSE_TYPE_DEFAULT - return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT) +def _map_house_type(raw: str | None) -> str | None: + """Наш house_type → вокабуляр Avito IMV. None = тип неизвестен, запрос не шлём. + + Сырое значение сначала прогоняем через общий normalize_house_type (#2007): он + знает camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / + aerocreteBlock / stalin / ...) и SCREAMING-вокабуляр Яндекса, а нераспознанное + ('other', 'wireframe', пустое) схлопывает в None. Приведения к нижнему регистру + тут мало: ключ канона пишется через подчёркивание (monolith_brick), поэтому + 'monolithbrick' в словарь не попадал. + + #2674: раньше здесь стоял дефолт 'panel' — и когда типа нет вовсе, и когда он + есть, но не распознан. Панель — почти самый дешёвый класс (медиана по нашим же + 2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k + ₽/м²), то есть дефолт систематически ЗАНИЖАЛ оценку: на проде 363 дома совсем + без типа + 75 домов с camelCase-типом (56 из них monolithBrick, −11.7% к + monolithic) уехали как панель. Теперь неизвестный тип → None → дом помечается + и запрос к площадке не тратится (см. _process_one_house). + """ + canon = normalize_house_type(raw) + if canon is None: + return None + return _HOUSE_TYPE_TO_IMV.get(canon) + + +def _map_renovation_type(repair_state: str | None) -> str: + """listings.repair_state → renovation_type вокабуляра Avito IMV. + + Переиспользуем _IMV_REPAIR_MAP эстиматора — единственный источник правды для + этого соответствия (needs_repair→required / standard→cosmetic / good→euro / + excellent→designer). Импорт ленивый: estimator тянет scraper_adapters, а тот + импортирует этот модуль (circular — см. блок импортов выше). + + #2674: раньше здесь стоял литерал 'cosmetic' — все 2685 запросов ушли как + «косметический ремонт», хотя мода по объявлениям этих же домов совсем другая + (standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 — косметика + лишь 36%). + + Неизвестный ремонт (498 домов из 2685 — ни одного объявления с repair_state) + ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: 'cosmetic' + (=standard) — это одновременно МОДА и МЕДИАННАЯ категория популяции + (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно + needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка. + У типа дома такой догадки нет: 'panel' — почти край шкалы, а не её середина. + + Асимметрия осознанная, а не недосмотр: поштучный путь эстиматора при + неизвестном ремонте IMV вообще не зовёт (estimator.py, `imv_renovation is not + None`), а домовой дефолтит — иначе теряем ещё ~32% домов очереди поверх тех, + что уже отсекает неизвестный тип дома. + """ + from app.services.estimator import _IMV_REPAIR_MAP # lazy — см. import-блок + + mapped = _IMV_REPAIR_MAP.get(repair_state) + if mapped is None and repair_state: + # Непустое, но незнакомое значение — признак дрейфа вокабуляра на ингесте + # (сырых repair-значений в listings больше, чем нормализованных). Паритет + # с house_type_normalizer, который такой случай уже логирует. + logger.debug("house_imv: unmapped repair_state %r — падаем в 'cosmetic'", repair_state) + return mapped or "cosmetic" # ── Region bbox prefix для Avito geocoder ──────────────────────────────────── @@ -135,7 +190,8 @@ def pick_lot_params(db: Session, house_id: int) -> dict: AS integer) AS floor, CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors) AS integer) AS total_floors, - mode() WITHIN GROUP (ORDER BY house_type) AS house_type + mode() WITHIN GROUP (ORDER BY house_type) AS house_type, + mode() WITHIN GROUP (ORDER BY repair_state) AS repair_state FROM listings WHERE house_id_fk = :hid AND rooms IS NOT NULL @@ -173,7 +229,12 @@ def pick_lot_params(db: Session, house_id: int) -> dict: "floor": floor, "floor_at_home": floor_at_home, "house_type": _map_house_type(row["house_type"] or (house and house["house_type"])), - "renovation_type": "cosmetic", + "renovation_type": _map_renovation_type(row["repair_state"]), + # has_balcony/has_loggia остаются константами намеренно (#2674): покрытие + # listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и две колонки + # противоречат друг другу (по has_balcony «есть» у 62%, а по + # balcony_loggia самый частый случай — loggia 5650 против balcony 2794). + # Мода по одному-двум объявлениям на таком покрытии — шум, а не данные. "has_balcony": True, "has_loggia": False, } @@ -284,26 +345,37 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat ) # 3. Suggestions + # #2674: до этого фикса в INSERT не входили image_link + area_m2/rooms/floor/ + # total_floors — колонки есть с миграции 064, но писатель их не заполнял + # (25 055 строк на проде с NULL во всех пяти). Ссылка на фото приходит в + # suggestions.items[].imageLink, метрики квартиры парсятся из title. for sug in result.suggestions: db.execute( text(""" INSERT INTO house_suggestions ( house_id, ext_item_id, title, address, price_rub, + area_m2, rooms, floor, total_floors, exposure_days, publish_date, - item_link, metro_name, metro_distance, + item_link, image_link, metro_name, metro_distance, has_good_price_badge, raw_payload, fetched_at ) VALUES ( :hid, :ext, :title, :addr, :price, + CAST(:area AS numeric), :rooms, :floor, :total_floors, :exp, :pdate, - :link, :mname, :mdist, + :link, :img, :mname, :mdist, :gpb, CAST(:raw AS jsonb), NOW() ) ON CONFLICT (house_id, ext_item_id) DO UPDATE SET title = EXCLUDED.title, price_rub = EXCLUDED.price_rub, + area_m2 = EXCLUDED.area_m2, + rooms = EXCLUDED.rooms, + floor = EXCLUDED.floor, + total_floors = EXCLUDED.total_floors, exposure_days = EXCLUDED.exposure_days, publish_date = EXCLUDED.publish_date, item_link = EXCLUDED.item_link, + image_link = EXCLUDED.image_link, metro_name = EXCLUDED.metro_name, metro_distance = EXCLUDED.metro_distance, has_good_price_badge = EXCLUDED.has_good_price_badge, @@ -316,9 +388,14 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat "title": sug.title, "addr": sug.address, "price": sug.price_rub, + "area": sug.area_m2, + "rooms": sug.rooms, + "floor": sug.floor, + "total_floors": sug.total_floors, "exp": sug.exposure_days, "pdate": sug.publish_date, "link": sug.item_url, + "img": sug.image_link, "mname": sug.metro_name, "mdist": sug.metro_distance, "gpb": sug.has_good_price_badge, @@ -500,8 +577,33 @@ async def backfill_house_imv( # + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853). # Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше # (поведение байт-в-байт идентично доспринтовому). + # + # #2698: proxy_provider/use_pool/environment — обязательная часть проводки, а не + # опция. Без них BrowserFetcher не кладёт "proxy" в тело POST /fetch-json, и сайдкар + # берёт свой env-прокси SCRAPER_PROXY_URL — на проде это узел пула id=1 + # (asocks-residential-1, provider_affinity='domclick'), который proxy_pool.acquire + # («affinity IN (provider,'any')» + защита последнего узла выделенной affinity от + # fallback) для avito не выдал бы НИКОГДА. Результат: 03.07-05.08 все 35 из 35 попыток + # каждого прогона падали на геокодере A (1240 домов — 503 «browser unavailable», затем + # 500 «Page.goto: NS_ERROR_PROXY_BAD_GATEWAY» и 403 от самого Авито), пока + # avito_city_sweep/avito_newbuilding_sweep в те же дни тянули сотни объявлений через + # ТОТ ЖЕ сайдкар и тот же инстанс камуфокса — они пул подключают (pipeline.py). Хуже: + # запрос без "proxy" в теле ещё и роняет сайдкару желаемый прокси на env → relaunch + # камуфокса на каждый дом (server.py::_ensure_browser). if settings.avito_imv_use_browser_fetcher: - async with BrowserFetcher(source="avito", endpoint=settings.browser_http_endpoint) as _bf: + # lazy import — тот же цикл scraper_adapters↔этот модуль, что и у RealScraperConfig. + from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig + + _cfg = RealScraperConfig() + async with BrowserFetcher( + source="avito", + endpoint=settings.browser_http_endpoint, + proxy_provider=RealProxyProvider(), + use_pool=_cfg.use_proxy_pool_browser, + # #2616 шаг 1: без environment прод-отказ «пул пуст» мёртв на этом пути — + # фетчер молча ушёл бы на тот самый env-прокси (см. _acquire_lease). + environment=_cfg.environment, + ) as _bf: await _run_loop(_bf) else: await _run_loop(None) @@ -652,6 +754,14 @@ async def _process_one_house( _mark_status(db, hid, "no_params", "no listings with rooms+area") return "no_params" + # #2674: тип дома неизвестен (нет ни в объявлениях, ни в houses — либо + # вокабуляр не распознан). Раньше такой дом молча уезжал как 'panel' и + # занижал оценку. Лучше не тратить запрос и честно пометить дом — тот же + # путь, что и при отсутствии комнат/площади. + if params["house_type"] is None: + _mark_status(db, hid, "no_params", "unknown house_type") + return "no_params" + address = house.get("address") or house.get("full_address") if not address: _mark_status(db, hid, "no_address", "house.address is NULL") diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py index 7b38d30e..8e1c67f3 100644 --- a/tradein-mvp/backend/app/services/location_index.py +++ b/tradein-mvp/backend/app/services/location_index.py @@ -13,9 +13,13 @@ POI-score его не улавливал (POI ranking ≠ цена). НОВЫЙ ПОКАЗАТЕЛЬ (location index): - location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки − + location_index_pct = (медиана ₽/м² сопоставимых листингов в радиусе точки − медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100 +«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие +(scraped_at в пределах LISTINGS_FRESH_DAYS — `is_active` на проде не равно «живо») И +только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы). + Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую ("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) — медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат @@ -45,6 +49,11 @@ from typing import Any from pydantic import BaseModel from sqlalchemy import text +# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте. +# Дублировать значение здесь нельзя: две константы разъедутся при первой же +# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена. +from app.services.estimator import LISTINGS_FRESH_DAYS + logger = logging.getLogger(__name__) # ── Гео-охват продукта: только Екатеринбург ────────────────────────────────── @@ -159,6 +168,43 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: # price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк # (см. _PRICE_PER_M2_SANITY_MIN/MAX выше). # +# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора. +# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение +# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision): +# +# было (только is_active) 30 222 строк 172 984 ₽/м² +# + только свежесть 11 453 строк 163 363 ₽/м² +# + только сегмент 11 219 строк 147 632 ₽/м² +# стало (оба) 7 715 строк 147 368 ₽/м² +# +# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из −14.8% +# сдвига городской медианы он даёт −14.7 п.п. Девелоперский прайс новостроек +# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011. +# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк +# пула 15 265 — новостройки, и гард выносит их заодно. +# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего +# −0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на +# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка — +# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных +# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней +# назад. Пул −31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а +# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% / +# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы. +# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число +# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо, +# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше). +# +# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем +# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт +# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до +# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что +# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический. +# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая. +# +# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов +# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок: +# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246. +# # bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если # сам продукт уже скрейпит соседние города области (city-sweep): географию location_index # явно ограничил владелец продукта. @@ -173,6 +219,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text( AND price_per_m2 IS NOT NULL AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND (geo_precision IS DISTINCT FROM 'city') + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -196,6 +244,8 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text( AND price_per_m2 IS NOT NULL AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND (geo_precision IS DISTINCT FROM 'city') + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -235,6 +285,7 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[ "lat": lat, "lon": lon, "radius_m": radius_m, + "fresh_days": LISTINGS_FRESH_DAYS, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, "bbox_south": _EKB_BBOX_SOUTH, @@ -257,6 +308,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]: db.execute( _MEDIAN_PPM2_CITYWIDE_SQL, { + "fresh_days": LISTINGS_FRESH_DAYS, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, "bbox_south": _EKB_BBOX_SOUTH, diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py index 712ece35..89d71531 100644 --- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py +++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py @@ -75,7 +75,10 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = { "yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории # Yandex Valuation enrichment (existing house attrs) "has_lift": ["cian_bti", "cian_detail", "yandex_valuation"], - "ceiling_height": ["cian_detail", "yandex_valuation"], + # #2699: запись "ceiling_height" удалена — колонки с таким именем в `houses` + # никогда не было (проверено на проде: 0 колонок LIKE '%ceiling%'), правило не + # могло сработать ни разу. Высота потолков — атрибут ОБЪЯВЛЕНИЯ + # (listings.ceiling_height_m), см. LISTING_FIELD_PRIORITY ниже. } # --------------------------------------------------------------------------- @@ -88,7 +91,10 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = { "area_m2": ["cian_serp", "avito_detail"], "living_area_m2": ["cian_serp"], "kitchen_area_m2": ["cian_serp", "avito_detail"], - "ceiling_height": ["cian_detail"], + # #2699: ключ — имя РЕАЛЬНОЙ колонки. Был "ceiling_height" (019), которую + # эстиматор не читает; канон — ceiling_height_m, и её пишут все три источника, + # а не только cian_detail. + "ceiling_height_m": ["cian_detail", "avito_detail", "yandex_detail"], "floor": ["cian_serp", "avito_detail"], "total_floors": ["cian_serp", "avito_detail"], "year_built": ["cian_serp"], diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 2b130d29..1effa723 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -1,11 +1,40 @@ """House cross-source matching — tiered algorithm. -Tier 0 (confidence 1.0): cadastral_number exact match on houses table. -Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive. -Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources. -Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases. -Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin). -New (confidence 1.0): INSERT new canonical house. +`match_or_create_house` (путь скрейпинга, создаёт дома): + Tier 0 (confidence 1.0): cadastral_number exact match on houses table. + Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources. + Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases. + Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin). + New (confidence 1.0): INSERT new canonical house. + +`match_house_readonly` (путь estimate-таргета, ничего не создаёт) дополнительно +имеет Tier 0.5 fias_exact — у него ЕСТЬ источник ФИАС (DaData /suggest в +`estimator.resolve_target_house`), см. docstring функции. + +ЧЕСТНОСТЬ ТИРОВ (#2674, замер на проде 2026-08-05, 49 502 строки house_sources): + fingerprint 58.97% · new 22.65% · geo_proximity 18.36% · + **cadastr_exact 0 · fias_exact 0** — верхние тиры не срабатывали НИ РАЗУ. + + • Tier 0.5 fias_exact из `match_or_create_house` УДАЛЁН: параметра `house_fias_id` + нет ни в Protocol `scraper_kit.contracts.HouseMatcher`, ни в + `app.services.scraper_adapters.RealMatcherAdapter`, ни у двух прямых вызывающих + (`estimator._save_yandex_history_items`, `scripts/backfill_listing_sources.py`) — + передать его было НЕКОМУ. Регресс сторожит + tests/test_matching_tier_reachability_2674.py. + • Tier 0 cadastr_exact ОСТАВЛЕН: он достижим по построению (`ScrapedLot. + building_cadastral_number` → адаптер → сюда), но площадки кадастр не отдают: + `listings.cadastral_number` 0/93 408, а все 28 504 заполненных + `listings.building_cadastral_number` — на 100% из локального гео-зеркала ЕГРН + (`tasks/cadastral_geo_match.py`, KNN ≤50 м), т.е. появляются ПОСЛЕ матчинга и + обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания KNN-кадастр + не инъективен — 656 из 3 260 значений накрывают >1 ГАР-здание (20.1%), это был бы + over-merge с confidence 1.0. Оставлен как рабочий приёмник на случай, если площадка + начнёт отдавать настоящий кадастр — но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр + `cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих. + Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда, + падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и + попутно сняло P1-страж «безномерный адрес без кадастра не создаём» — по дому на + квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное. Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3 """ @@ -46,9 +75,8 @@ def match_or_create_house( *, year_built: int | None = None, building_cadastral_number: str | None = None, - cadastral_number: str | None = None, - house_fias_id: str | None = None, source_url: str | None = None, + city: str | None = None, ) -> tuple[int | None, float, str]: """Match existing house or create new canonical record. @@ -58,21 +86,30 @@ def match_or_create_house( for an unknown address could both miss Tier 0-3 and each INSERT a duplicate house row. Closes finding #1 from 2026-05-24 audit. + NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля. + ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС. + Args: - house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream - (e.g. DaData /clean/address). Enables Tier 0.5 fias_exact — additive and - optional, existing callers are unaffected. + city: город-цель развёртки, собравшей эту карточку (`save_listings(city=…)`, + он же `listings.city`) — НЕЗАВИСИМОЕ от строки адреса наблюдение города + (#2777). Нужен ровно там, где адресный токен города бессилен: областной + формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный + ключ Tier-2a вырождается в один нормализованный адрес и становится + глобально уникальным. Опционален: вызывающие без sweep-контекста + (estimate-путь, ad-hoc скрипты) передают None → поведение прежнее. + Про независимость: в #2690 доказано, что усиление ключа полем, выведенным + из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает — + здесь признак приходит другим каналом (какой город запрашивала развёртка). Returns: (house_id, confidence ∈ [0.0, 1.0], method ∈ { - 'cadastr_exact', 'fias_exact', 'source_exact', 'fingerprint', + 'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new', 'no_house_number' }) house_id is None only for the 'no_house_number' terminal case below. Method values: 'cadastr_exact' — matched by cadastral number (confidence 1.0) - 'fias_exact' — matched by house_fias_id (ГАР OBJECTGUID) (confidence 0.95) 'source_exact' — already in house_sources for this source+ext_id (confidence 1.0) 'fingerprint' — matched by address fingerprint (confidence 0.9) 'geo_proximity' — matched by geo within 30 m (confidence 0.7) @@ -87,7 +124,16 @@ def match_or_create_house( 'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building identity, so cad-carrying rows stay exempt (Tier 0 owns them). """ - cad = building_cadastral_number or cadastral_number + # ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`, + # где второе — кадастр КВАРТИРЫ (у каждой свой), и параметр `cadastral_number` тоже убран из + # сигнатуры. Пока площадки не отдают ни того ни другого, фолбэк спал; но он и есть ловушка, + # ради которой мы «оставили рабочий приёмник»: начни Циан отдавать `offer["cadastralNumber"]` + # (парсер читает именно его), квартирный номер поехал бы в ключ ЗДАНИЯ. Tier 0 не сматчил бы + # никогда (у каждой квартиры свой номер) → падение в New-house INSERT → номер КВАРТИРЫ + # проштампован в houses.cadastral_number, плюс снят P1-страж ниже («безномерный адрес без + # кадастра не создаём» — `cad` там же и разрешает создание). Две квартиры одного дома дали бы + # два дома — то самое дробление, против которого Tier 0 и заведён. + cad = building_cadastral_number # Compute fingerprint early so we can acquire the advisory lock before any tier reads. fp = address_fingerprint(address, lat, lon) @@ -136,34 +182,10 @@ def match_or_create_house( logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad) return (house_id, 1.0, "cadastr_exact") - # Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive. - # Stable ORDER BY id so concurrent/duplicate rows resolve deterministically. - if house_fias_id: - row = ( - db.execute( - text( - "SELECT id FROM houses " - "WHERE lower(house_fias_id) = lower(CAST(:fias AS text)) " - "ORDER BY id ASC LIMIT 1" - ), - {"fias": house_fias_id}, - ) - .mappings() - .first() - ) - if row: - house_id = int(row["id"]) - _upsert_house_source( - db, - house_id=house_id, - ext_source=ext_source, - ext_id=ext_id, - method="fias_exact", - confidence=0.95, - ) - _insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source) - logger.info("house match fias_exact house_id=%s fias=%s", house_id, house_fias_id) - return (house_id, 0.95, "fias_exact") + # Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было + # некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих + # такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из + # 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly. # Tier 1: source+ext_id already registered in house_sources row = ( @@ -203,18 +225,40 @@ def match_or_create_house( # SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of # scope, only relevant once the oblast sweep is enabled. # - # EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ - # city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the - # dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b - # exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito - # SERP format) carries no signal here and is deliberately left on the unchanged path; that - # residual needs sweep-context and is out of this fix's scope. - _resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None + # EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to + # be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all + # (resolved city None) run Tier-2a/2b exactly as before. + # + # #2777: the residual the comment above used to describe as out of scope — a BARE oblast + # card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg. + # The sweep already knows which city it was crawling and stamps it on the listing row + # (save_listings → listings.city); that observation just never reached this guard, so + # 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod + # 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token + # at all — i.e. a globally unique 'street + number' that ANY city's card can hit. + # The address token still wins when present (it describes THIS card; the sweep city + # describes the batch). + _resolved_city = None + if lat is None and lon is None: + _resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None) _skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN + # Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со + # «сшитыми» городами по метке listings.city): + # • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого + # города → страж срабатывает; + # • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж + # молчит: города владельца алиаса мы не знаем (в house_address_aliases его + # нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303 + # бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1). + # • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км + # разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель + # «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый + # пункт; это ограничение данных, а не недоделка стража. if _skip_oblast_alias: logger.info( - "house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s", + "house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s", _resolved_city, + city, norm_addr, ext_source, ) diff --git a/tradein-mvp/backend/app/services/matching/listings.py b/tradein-mvp/backend/app/services/matching/listings.py index f2b94f72..ceaae20c 100644 --- a/tradein-mvp/backend/app/services/matching/listings.py +++ b/tradein-mvp/backend/app/services/matching/listings.py @@ -246,7 +246,23 @@ def _upsert_listing_source( source_url: str | None, source_data: dict | None, ) -> None: - """Insert or refresh listing_sources row for this source+ext_id.""" + """Insert or refresh listing_sources row for this source+ext_id. + + Отметки времени — statement_timestamp(), НЕ NOW() (#2731). Этот upsert вызывается + ПОСТРОЧНО из save_listings (hook `_link_listing_to_house`), а транзакция batch'а + коммитится один раз в конце, поэтому NOW() (== transaction_timestamp) давал одну + метку на весь вызов: прод-замер 2026-08-06 — 219 строк на 1 метку в 11:00, + 235/1 в 10:00, 297/1 в 09:00, и так каждый час. + + Чинится вместе с listings.scraped_at/last_seen_at, а не отдельно: сегодня + listings.last_seen_at = listing_sources.last_seen_at у 100% пар (2407 из 2407 за + сутки) именно потому, что обе колонки берут одну транзакционную метку. Почини + только одну — вторая осталась бы замороженной на старте batch'а, и расхождение + выросло бы с миллисекунд (честная разница двух записей) до длительности прогона. + + Все три колонки пишутся ОДНИМ statement'ом, поэтому statement_timestamp() даёт им + одинаковое значение; clock_timestamp() развёл бы их на микросекунды. + """ raw = json.dumps(source_data) if source_data is not None else None db.execute( text(""" @@ -257,15 +273,15 @@ def _upsert_listing_source( price_rub, area_m2, floor, rooms_count, raw_payload ) VALUES ( CAST(:lid AS bigint), :s, :e, - CAST(:c AS real), :m, NOW(), NOW(), - :url, NOW(), + CAST(:c AS real), :m, statement_timestamp(), statement_timestamp(), + :url, statement_timestamp(), CAST(:p AS bigint), CAST(:a AS numeric), :fl, :rc, CAST(:raw AS jsonb) ) ON CONFLICT (ext_source, ext_id) DO UPDATE SET confidence = GREATEST(EXCLUDED.confidence, listing_sources.confidence), - last_seen_at = NOW(), - last_scraped_at = NOW(), + last_seen_at = statement_timestamp(), + last_scraped_at = statement_timestamp(), price_rub = COALESCE(EXCLUDED.price_rub, listing_sources.price_rub), area_m2 = COALESCE(EXCLUDED.area_m2, listing_sources.area_m2), floor = COALESCE(EXCLUDED.floor, listing_sources.floor), diff --git a/tradein-mvp/backend/app/services/payments/__init__.py b/tradein-mvp/backend/app/services/payments/__init__.py new file mode 100644 index 00000000..6a78136f --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/__init__.py @@ -0,0 +1,20 @@ +"""Т-Банк интернет-эквайринг — чистый интеграционный слой (PR-C). + +Модули здесь НЕ импортируют `app.core.config` и не пишут в БД: все секреты +(`terminal_key`, `password`, `base_url`) принимаются аргументами функций/ +конструктора. Причина — параллельный PR-B вводит эти поля в `config.py`, +а проводку (роутер, `_PUBLIC_PATHS`, `payments`-таблицы, статус-машина) +делает следующий PR-D. См. `mera-tbank-acquiring-recon.md` (корень репо) +§3/§9 для полной схемы разбивки. + +- `token.py` — подпись `Token` запросов + проверка подписи нотификаций + (никогда не кидает исключение на враждебном входе). +- `notification.py` — строгий типизированный разбор тела нотификации ПОСЛЕ + проверки подписи (`parse_notification`) — сырой `dict` дальше не уходит. +- `receipt.py` — сборка `Receipt` (54-ФЗ, ФФД 1.05) для услуги. +- `tbank_client.py` — httpx-клиент `Init/GetState/CheckOrder/Confirm/Cancel`. + +Docs: https://developer.tbank.ru/eacq/intro +""" + +from __future__ import annotations diff --git a/tradein-mvp/backend/app/services/payments/notification.py b/tradein-mvp/backend/app/services/payments/notification.py new file mode 100644 index 00000000..48faacac --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/notification.py @@ -0,0 +1,116 @@ +"""Строгий типизированный разбор нотификации Т-Банк — ПОСЛЕ проверки подписи. + +Вызывать `parse_notification()` только когда `token.verify_notification_token(...)` +уже вернул `True`. Разбор здесь НЕ проверяет подпись повторно — он только +превращает уже доверенный (по подписи) `dict` в типизированный объект, чтобы +сырой `dict` не утекал дальше в бизнес-логику (статус-машину платежа, запись +в БД). + +ПОЧЕМУ строгий разбор — самостоятельный слой защиты, а не формальность: +алгоритм подписи Т-Банка конкатенирует значения полей БЕЗ разделителя между +ними (см. `token.py`, docstring модуля, шаг 4). Из-за этого символы могут +"перекладываться" между лексикографически соседними ключами так, что итоговая +строка для SHA-256 не меняется, хотя значения полей — меняются. Проверено +живым расчётом на официальном эталонном векторе: `Amount=1111, CardId="000000"` +даёт тот же Token, что и `Amount=11, CardId="11000000"` (доп. `1` "перетекла" +из `Amount` в начало `CardId`, потому что `Amount` < `CardId` лексикографически +и обе стоят подряд в конкатенации). Значит подпись сама по себе НЕ гарантирует, +что банк прислал именно ту сумму, которую записал у себя платёжный сервис — +это СВОЙСТВО алгоритма банка, менять его нельзя (мы не управляем форматом +Token, который реально пришлёт банк на проде). + +КОНТРАКТ ДЛЯ PR-D (публичная ручка нотификации) — единственная реальная защита +от описанного выше перекладывания: `amount_kopecks` из `parse_notification()` +ОБЯЗАН быть сверен с уже сохранённым `payments.amount_kopecks` в БД (запись, +созданная на `init_payment()`, найденная по `order_id`/`payment_id` из этой же +нотификации) ДО того, как нотификация будет принята как валидное событие. +Если сумма из нотификации не совпадает с суммой в БД — это либо подделанная +нотификация (перекладывание символов дало другой `OrderId`/`Amount`-ключ и +подпись всё равно сошлась), либо рассинхронизация, но НЕ штатный кейс — то и +другое должно быть отказом, а не «примерно похоже, примем». +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +class NotificationParseError(ValueError): + """Поле нотификации не соответствует ожидаемому типу — отказ, не догадка.""" + + +@dataclass(frozen=True, slots=True) +class TBankNotification: + """Типизированное тело нотификации Т-Банка ПОСЛЕ успешной проверки подписи. + + `amount_kopecks` здесь — то, что ПРИСЛАЛ банк в текущем HTTP-запросе, а + НЕ подтверждённый источник истины сам по себе. См. docstring модуля — + сверка с `payments.amount_kopecks` в БД обязательна на вызывающей стороне. + """ + + success: bool + status: str + order_id: str + payment_id: str + terminal_key: str + amount_kopecks: int + + +def parse_notification(payload: dict[str, Any]) -> TBankNotification: + """Строгий разбор `payload` в `TBankNotification`. + + Вызывать ТОЛЬКО после `token.verify_notification_token(payload, password) + is True` — эта функция подпись не проверяет. + + Правила (без исключений, без «примерно разберём»): + - `Success` — только настоящий `bool` (не строка `"true"`, не `1`); + - `Amount` — только `int`; `bool` — подкласс `int` в Python + (`isinstance(True, int) is True`), поэтому проверяется и отсекается + ДО проверки на `int`, иначе `Success`-подобное поле молча прошло бы + как сумма; + - `Status`, `OrderId`, `PaymentId`, `TerminalKey` — только непустой `str`. + + Любое несоответствие — `NotificationParseError` с указанием поля, + ожидаемого типа и того, что реально пришло. + """ + if not isinstance(payload, dict): + raise NotificationParseError(f"payload должен быть dict, получено {type(payload).__name__}") + + return TBankNotification( + success=_require_strict_bool(payload, "Success"), + status=_require_nonempty_str(payload, "Status"), + order_id=_require_nonempty_str(payload, "OrderId"), + payment_id=_require_nonempty_str(payload, "PaymentId"), + terminal_key=_require_nonempty_str(payload, "TerminalKey"), + amount_kopecks=_require_strict_int(payload, "Amount"), + ) + + +def _require_strict_bool(payload: dict[str, Any], key: str) -> bool: + value = payload.get(key) + if not isinstance(value, bool): + raise NotificationParseError( + f"{key} должен быть bool, получено {type(value).__name__}={value!r}" + ) + return value + + +def _require_strict_int(payload: dict[str, Any], key: str) -> int: + value = payload.get(key) + # bool — подкласс int в Python: проверяем и отсекаем ДО isinstance(value, int), + # иначе True/False молча прошли бы как Amount=1/Amount=0. + if isinstance(value, bool) or not isinstance(value, int): + raise NotificationParseError( + f"{key} должен быть int (не bool/str/float), получено {type(value).__name__}={value!r}" + ) + return value + + +def _require_nonempty_str(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value: + raise NotificationParseError( + f"{key} должен быть непустой str, получено {type(value).__name__}={value!r}" + ) + return value diff --git a/tradein-mvp/backend/app/services/payments/receipt.py b/tradein-mvp/backend/app/services/payments/receipt.py new file mode 100644 index 00000000..e1970dd7 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/receipt.py @@ -0,0 +1,143 @@ +"""Сборка объекта `Receipt` (54-ФЗ, ФФД 1.05) для чека Т-Банк эквайринга. + +Продукт продаёт УСЛУГУ (не товар) — везде фиксированы `PaymentObject="service"` +и `PaymentMethod="full_payment"` (одномоментная оплата за уже готовую услугу, +без предоплат/кредита/частичных расчётов). + +Схема (`Receipt` в `Init`, ФФД 1.05) — источник, снят живым запросом +2026-08-06: https://developer.tbank.ru/eacq/api/init + +- `Email` ИЛИ `Phone` — обязательно хотя бы одно (перекрёстный required). +- `Taxation` — обязателен: `osn|usn_income|usn_income_outcome|esn|patent`. +- `Items[].Name` — <=128 символов, обязателен. +- `Items[].Price`/`Quantity`/`Amount` — числа, В КОПЕЙКАХ; `Amount` — это + произведение `Price * Quantity` (дословно из API-reference). +- `Items[].Tax` — ставка НДС. Актуальный список 2026 (Init API reference): + `none|vat0|vat5|vat7|vat10|vat22|vat105|vat107|vat110|vat122`. + `vat20`/`vat120` В СПИСКЕ НЕТ — сняты, не использовать (см. recon §6/§11 + в `mera-tbank-acquiring-recon.md`, корень репо). + +ВАЖНО: `Receipt` НЕ участвует в расчёте `Token` (`token.py` отсекает любые +вложенные `dict`/`list` из подписи) — это архитектурно гарантировано самой +функцией `token.sign`, а не соглашением здесь. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +TaxRate = Literal[ + "none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122" +] + +Taxation = Literal["osn", "usn_income", "usn_income_outcome", "esn", "patent"] + +_ALLOWED_TAX_RATES: frozenset[str] = frozenset( + {"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"} +) +_ALLOWED_TAXATION: frozenset[str] = frozenset( + {"osn", "usn_income", "usn_income_outcome", "esn", "patent"} +) + +_MAX_ITEM_NAME_LEN = 128 +_MAX_ITEMS = 100 # "Количество товаров в чеке — не больше 100" (API reference) + + +class ReceiptBuildError(ValueError): + """Невалидные данные для сборки Receipt — не пройдёт валидацию Т-Банка.""" + + +@dataclass(frozen=True, slots=True) +class ReceiptItem: + """Одна позиция чека — услуга. `price_kopecks`/`quantity` — целые копейки/штуки.""" + + name: str + price_kopecks: int + quantity: int = 1 + tax: TaxRate = "none" + + @property + def amount_kopecks(self) -> int: + """Items[].Amount = Price * Quantity (дословно из API reference).""" + return self.price_kopecks * self.quantity + + def to_payload(self) -> dict[str, Any]: + if not self.name or len(self.name) > _MAX_ITEM_NAME_LEN: + raise ReceiptBuildError( + f"Items[].Name должен быть 1..{_MAX_ITEM_NAME_LEN} символов, " + f"получено {len(self.name)}" + ) + if self.price_kopecks <= 0: + raise ReceiptBuildError("Items[].Price должен быть > 0 (в копейках)") + if self.quantity <= 0: + raise ReceiptBuildError("Items[].Quantity должен быть > 0") + if self.tax not in _ALLOWED_TAX_RATES: + raise ReceiptBuildError( + f"Items[].Tax={self.tax!r} не входит в актуальный список Т-Банка " + f"({sorted(_ALLOWED_TAX_RATES)}) — vat20/vat120 сняты, не используются" + ) + return { + "Name": self.name, + "Price": self.price_kopecks, + "Quantity": self.quantity, + "Amount": self.amount_kopecks, + "Tax": self.tax, + "PaymentMethod": "full_payment", + "PaymentObject": "service", + } + + +def build_receipt( + *, + items: list[ReceiptItem], + taxation: Taxation, + email: str | None = None, + phone: str | None = None, +) -> dict[str, Any]: + """Собирает `Receipt` (ФФД 1.05) для одного заказа (может быть >1 позиции). + + Инвариант «сумма Items[].Amount == Init.Amount» здесь НЕ проверяется — + `Receipt` строится независимо от `Init`-payload заказа. Сверка — на + вызывающей стороне (`service.py`, следующий PR) через + `receipt_total_kopecks(receipt) == init_amount_kopecks`. См. тест + `test_receipt_total_matches_order_amount_invariant` в + `tests/test_payments_receipt.py`, который проверяет именно эту сверку. + """ + if not items: + raise ReceiptBuildError("Receipt.Items не может быть пустым") + if len(items) > _MAX_ITEMS: + raise ReceiptBuildError(f"Receipt.Items — не больше {_MAX_ITEMS} позиций") + if taxation not in _ALLOWED_TAXATION: + raise ReceiptBuildError( + f"Taxation={taxation!r} не входит в допустимый список ({sorted(_ALLOWED_TAXATION)})" + ) + + email_norm = (email or "").strip() or None + phone_norm = (phone or "").strip() or None + if not email_norm and not phone_norm: + raise ReceiptBuildError("Нужно указать Email или Phone (хотя бы одно)") + + payload: dict[str, Any] = { + "Taxation": taxation, + "Items": [item.to_payload() for item in items], + } + if email_norm: + payload["Email"] = email_norm + if phone_norm: + payload["Phone"] = phone_norm + return payload + + +def receipt_total_kopecks(receipt: dict[str, Any]) -> int: + """Сумма `Items[].Amount` — для сверки вызывающей стороной с `Init.Amount`.""" + items = receipt.get("Items") + if not isinstance(items, list): + return 0 + total = 0 + for item in items: + if isinstance(item, dict): + amount = item.get("Amount") + if isinstance(amount, int): + total += amount + return total diff --git a/tradein-mvp/backend/app/services/payments/tbank_client.py b/tradein-mvp/backend/app/services/payments/tbank_client.py new file mode 100644 index 00000000..e9baa367 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/tbank_client.py @@ -0,0 +1,279 @@ +"""httpx-клиент Т-Банк эквайринга (Init/GetState/CheckOrder/Confirm/Cancel). + +Стиль и обработка ошибок — по образцу +`app.services.tgbot.client.TelegramClient`: единственные нужные методы, +не тянем отдельный SDK ради пяти HTTP-вызовов. + +Модуль НЕ импортирует `app.core.config` — все параметры (`terminal_key`, +`password`, `base_url`) передаются в конструктор явно аргументами. +Архитектурное ограничение PR-C (см. `app/services/payments/__init__.py`): +параллельный PR-B вводит эти поля в `config.py`, проводку делает PR-D. + +Docs: https://developer.tbank.ru/eacq/api + +Ретраи: + - Сетевые ошибки (timeout/connect) и HTTP 5xx — экспоненциальный backoff, + capped на `_MAX_BACKOFF_S`. + - Любая 4xx — НЕ ретраится (запрос некорректен / права не те — повтор + транспортного вызова не поможет), сразу `TBankApiError`. + - Бизнес-отказ (HTTP 200, но `Success: false` в теле) — тоже НЕ + ретраится: это содержательный ответ банка, а не сбой транспорта. + +БЮДЖЕТ ВРЕМЕНИ (важно для PR-D): worst case одного вызова любого метода — +около 74 с (4 попытки × `_DEFAULT_TIMEOUT_S`=15 с = 60 с, плюс backoff между +попытками 2+4+8=14 с при `_DEFAULT_MAX_RETRIES`=3). Т-Банк даёт на ответ на +нотификацию окно порядка 10 с — этот бюджет в 74 с в него заведомо не +укладывается. Значит: исходящий HTTP-вызов к `TBankClient` (в т.ч. +`get_state`/`confirm`/`cancel` для сверки/реконсиляции по нотификации) +ВНУТРИ обработчика публичной ручки нотификации ЗАПРЕЩЁН — обработчик обязан +только валидировать/сохранить событие и ответить `"OK"`, а любая сверка с +банком (`GetState`/`CheckOrder`) — асинхронно, вне HTTP-цикла ответа банку. + +БЕЗОПАСНОСТЬ: `password` и `Token` НИКОГДА не попадают в `logger.*` — +логируем только имя метода, HTTP-статус, `ErrorCode`/`Message`/`Details` +из ответа банка. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import httpx + +from app.services.payments.token import sign + +logger = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT_S = 15.0 +_MAX_BACKOFF_S = 30.0 +_DEFAULT_MAX_RETRIES = 3 + +DEFAULT_BASE_URL = "https://securepay.tinkoff.ru" + + +class TBankApiError(Exception): + """T-Bank Acquiring API ответил ошибкой (HTTP-ошибка или `Success: false`).""" + + def __init__(self, method: str, error_code: str, message: str, details: str = "") -> None: + self.method = method + self.error_code = error_code + self.message = message + self.details = details + text = f"T-Bank API {method} failed: [{error_code}] {message}" + if details: + text += f" — {details}" + super().__init__(text) + + +def _error_from_body(response: httpx.Response) -> tuple[str, str, str]: + """Парсит (ErrorCode, Message, Details) из тела ответа; fallback на HTTP-статус.""" + try: + data = response.json() + except ValueError: + return str(response.status_code), (response.text or "")[:200], "" + if not isinstance(data, dict): + return str(response.status_code), str(data)[:200], "" + error_code = str(data.get("ErrorCode", response.status_code)) + message = str(data.get("Message", "")) + details = str(data.get("Details", "")) + return error_code, message, details + + +class TBankClient: + """Клиент Т-Банк эквайринга на `httpx.AsyncClient`. + + Каждый вызов — отдельное короткоживущее соединение (без общего + connection-pool между вызовами; частота вызовов в checkout-потоке + низкая, держать долгоживущий клиент не нужно — тот же паттерн, что + `TelegramClient`). + """ + + def __init__( + self, + *, + terminal_key: str, + password: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = _DEFAULT_TIMEOUT_S, + ) -> None: + self._terminal_key = terminal_key + self._password = password + self._base = f"{base_url.rstrip('/')}/v2" + self._timeout = timeout + + def _signed_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + """Добавляет `TerminalKey` + `Token`. Сам `password` в тело не уходит.""" + body: dict[str, Any] = {"TerminalKey": self._terminal_key, **payload} + body["Token"] = sign(body, self._password) + return body + + async def _request( + self, + method: str, + payload: dict[str, Any], + *, + max_retries: int = _DEFAULT_MAX_RETRIES, + ) -> dict[str, Any]: + """POST `method` с подписанным JSON-телом. Ретраит network/5xx, иначе raise сразу.""" + body = self._signed_payload(payload) + url = f"{self._base}/{method}" + attempt = 0 + + while True: + attempt += 1 + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.post(url, json=body) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + if attempt > max_retries: + logger.error( + "tbank client: %s — network error после %d попыток: %s", + method, + attempt, + exc, + ) + raise TBankApiError(method, "network_error", str(exc)) from exc + backoff = min(2.0**attempt, _MAX_BACKOFF_S) + logger.warning( + "tbank client: %s — network error (попытка %d/%d) — retry через %.0fs", + method, + attempt, + max_retries, + backoff, + ) + await asyncio.sleep(backoff) + continue + + if response.status_code >= 500: + if attempt > max_retries: + error_code, message, details = _error_from_body(response) + logger.error( + "tbank client: %s — HTTP %d после %d попыток, сдаёмся", + method, + response.status_code, + attempt, + ) + raise TBankApiError(method, error_code, message, details) + backoff = min(2.0**attempt, _MAX_BACKOFF_S) + logger.warning( + "tbank client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs", + method, + response.status_code, + attempt, + max_retries, + backoff, + ) + await asyncio.sleep(backoff) + continue + + if response.status_code >= 400: + # 4xx кроме сетевых сценариев выше — запрос некорректен, повтор не поможет. + error_code, message, details = _error_from_body(response) + raise TBankApiError(method, error_code, message, details) + + try: + data = response.json() + except ValueError as exc: + raise TBankApiError(method, "invalid_json", str(exc)) from exc + + if not isinstance(data, dict): + raise TBankApiError(method, "invalid_response", "тело ответа — не JSON-объект") + + if not data.get("Success"): + error_code = str(data.get("ErrorCode", response.status_code)) + message = str(data.get("Message", "")) + details = str(data.get("Details", "")) + raise TBankApiError(method, error_code, message, details) + + return data + + async def init_payment( + self, + *, + order_id: str, + amount_kopecks: int, + description: str = "", + notification_url: str | None = None, + success_url: str | None = None, + fail_url: str | None = None, + receipt: dict[str, Any] | None = None, + pay_type: str | None = None, + data: dict[str, str] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`. + + КОНТРАКТ ДЛЯ PR-D (обработка сетевой ошибки вызывающей стороной): + после `TBankApiError` от `Init` (в т.ч. `error_code == "network_error"` — + таймаут/обрыв) НЕЛЬЗЯ слепо повторять `init_payment()` с тем же + `order_id` — неизвестно, дошёл ли исходный запрос до банка до обрыва + соединения. Слепой повтор может создать ВТОРОЙ холд на тот же + `OrderId`. Разбираться нужно через `check_order(order_id=...)` — + он возвращает уже существующие платежи по заказу — и только по его + результату решать, нужен ли новый `Init`. + """ + payload: dict[str, Any] = {"OrderId": order_id, "Amount": amount_kopecks} + if description: + payload["Description"] = description + if notification_url: + payload["NotificationURL"] = notification_url + if success_url: + payload["SuccessURL"] = success_url + if fail_url: + payload["FailURL"] = fail_url + if receipt: + payload["Receipt"] = receipt + if pay_type: + payload["PayType"] = pay_type + if data: + payload["DATA"] = data + return await self._request("Init", payload) + + async def get_state(self, *, payment_id: str) -> dict[str, Any]: + """`POST /v2/GetState` — статус платежа по `PaymentId`.""" + return await self._request("GetState", {"PaymentId": payment_id}) + + async def check_order(self, *, order_id: str) -> dict[str, Any]: + """`POST /v2/CheckOrder` — список платежей по `OrderId` (для реконсиляции).""" + return await self._request("CheckOrder", {"OrderId": order_id}) + + async def confirm( + self, + *, + payment_id: str, + amount_kopecks: int | None = None, + receipt: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`). + + КОНТРАКТ ДЛЯ PR-D (обработка ошибки вызывающей стороной): после + `TBankApiError` от `Confirm` (в т.ч. сетевой таймаут) слепой вызов + `cancel()` для того же `payment_id` ЗАПРЕЩЁН. Таймаут/обрыв мог + прийти УЖЕ ПОСЛЕ того, как банк фактически подтвердил холд — + `Confirm` состоялся на стороне банка, а ответ до клиента не дошёл. + В этом случае `cancel()` вернёт клиенту уже захваченные деньги. + Правильная последовательность: сначала `get_state(payment_id=...)`, + и только по актуальному статусу решать, нужен ли `cancel()`. + """ + payload: dict[str, Any] = {"PaymentId": payment_id} + if amount_kopecks is not None: + payload["Amount"] = amount_kopecks + if receipt: + payload["Receipt"] = receipt + return await self._request("Confirm", payload) + + async def cancel( + self, + *, + payment_id: str, + amount_kopecks: int | None = None, + receipt: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Cancel` — отмена/возврат (полный, если `amount_kopecks` не передан).""" + payload: dict[str, Any] = {"PaymentId": payment_id} + if amount_kopecks is not None: + payload["Amount"] = amount_kopecks + if receipt: + payload["Receipt"] = receipt + return await self._request("Cancel", payload) diff --git a/tradein-mvp/backend/app/services/payments/token.py b/tradein-mvp/backend/app/services/payments/token.py new file mode 100644 index 00000000..7a74a075 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/token.py @@ -0,0 +1,141 @@ +"""Подпись `Token` запросов Т-Банк эквайринга и проверка подписи нотификаций. + +Docs (проверено живым запросом к doc-порталу, 2026-08-06): +- https://developer.tbank.ru/eacq/intro/developer/token — формирование Token. +- https://developer.tbank.ru/eacq/intro/developer/notification + (раздел «Проверить токен уведомлений») — тот же алгоритм для входящих + нотификаций. + +Алгоритм (идентичен для исходящего запроса и для проверки нотификации): + +1. Берём ТОЛЬКО плоские поля payload: исключаем ключ `Token`, исключаем + `None`, исключаем значения-`dict`/`list` (документация формулирует это + как «кроме параметра Token и вложенных объектов (Data, Receipt)» — + здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые + вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items` + или `Shops`, отсекаются одинаково, потому что все они не примитивы). +2. `bool` → `"true"`/`"false"` (нижний регистр); `int` → строка через `str()`; + `str` — как есть. `float` НЕ поддерживается — падаем явной ошибкой (формат + дробных чисел не задокументирован Т-Банком, см. `_stringify_value`). +3. Добавляем пару `Password: <пароль_терминала>`. +4. Сортируем пары по имени ключа (лексикографически по строке ключа), + конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку. +5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре. + +Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с +doc-портала — оба подтверждены живым запросом, не выдуманы. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +_EXCLUDED_KEYS = frozenset({"Token"}) + + +class TokenSigningError(ValueError): + """Поле не может быть однозначно сериализовано в подписываемую строку.""" + + +def _stringify_value(value: bool | int | float | str) -> str: + """Приводит плоское значение к строке по правилам Т-Банка. + + `bool` проверяем ДО `int`: в Python `bool` — подкласс `int` + (`isinstance(True, int) is True`), поэтому порядок веток важен — + иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`. + + `float` НЕ поддерживается — падаем с `TokenSigningError`, а не + угадываем формат. Документация Т-Банка не описывает сериализацию + дробных чисел в подписи; прежняя реализация (`format(value, "f")` + + rstrip нулей) была неподтверждённой догадкой, и она расходится с тем, + что реально уходит в JSON-теле запроса: `0.1 + 0.2` подписывался бы + как `"0.3"`, а `json.dumps(0.1 + 0.2)` даёт `"0.30000000000000004"` — + Token не соответствовал бы фактическому телу. Денежные суммы (`Amount`) + в этом API всегда целые копейки (`int`); для денежного пути правильнее + явно упасть на нецелом значении, чем подписать не то, что уйдёт в сеть. + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + raise TokenSigningError( + f"float в подписываемых полях не поддерживается (получено {value!r}) — " + "формат дробных чисел не описан в документации Т-Банка, см. docstring " + "_stringify_value" + ) + return str(value) + + +def _flatten_signable_fields(payload: dict[str, Any]) -> dict[str, str]: + """Плоские поля payload, готовые к конкатенации: без Token/None/dict/list.""" + result: dict[str, str] = {} + for key, value in payload.items(): + if key in _EXCLUDED_KEYS or value is None: + continue + if isinstance(value, dict | list): + continue + result[key] = _stringify_value(value) + return result + + +def sign(payload: dict[str, Any], password: str) -> str: + """Считает `Token` для исходящего запроса (Init/GetState/CheckOrder/...). + + `payload` — тело запроса ДО добавления поля `Token` (поле `Password` + самому передавать не нужно — функция добавляет его сама и удаляет + участие любых вложенных объектов автоматически). + """ + fields = _flatten_signable_fields(payload) + fields["Password"] = password + raw = "".join(fields[key] for key in sorted(fields)) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def verify_notification_token(payload: dict[str, Any], password: str) -> bool: + """Проверяет `Token` входящей нотификации: пересчёт + `hmac.compare_digest`. + + `payload` — полное тело нотификации, включая присланный `Token` (сам + алгоритм сборки исключает ключ `Token` из подписи — см. `_EXCLUDED_KEYS`). + + Возвращает `False`, если в payload нет строкового непустого `Token` + (нечего сравнивать) — вызывающая сторона обязана трактовать это как + отказ в обработке нотификации, а не как «пропустить проверку». + + НИКОГДА не поднимает исключение — на любом враждебном/мусорном входе + (не `dict`, не-ASCII `Token`, поля, которые ломают сериализацию внутри + `sign()`) возвращает `False`. Это обязательное свойство для публичной + ручки нотификации (PR-D): необработанное исключение здесь — это + неаутентифицированный HTTP 500 в ответ банку, а любой ответ, отличный + от `"OK"`, банк трактует как временный сбой и ретраит уведомление + почасово в течение суток. Конкретные причины двух проверок ниже: + - `payload` не `dict` (например список) → `.get()` кинул бы + `AttributeError` без явной проверки типа; + - `Token` с не-ASCII символами → `hmac.compare_digest` на строках + требует ASCII и иначе кидает `TypeError` (документированное + ограничение stdlib, не баг). + """ + if not isinstance(payload, dict): + return False + received_token = payload.get("Token") + if not isinstance(received_token, str) or not received_token or not received_token.isascii(): + return False + try: + expected_token = sign(payload, password) + except Exception: + # Мусорное поле где-то ещё в payload (например float — см. + # `_stringify_value`) не должно валить проверку подписи в исключение. + logger.warning( + "verify_notification_token: sign() упал на входящем payload — трактуем как отказ", + exc_info=True, + ) + return False + try: + return hmac.compare_digest(expected_token, received_token) + except TypeError: + return False diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index b31411da..64ba11e2 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -21,8 +21,10 @@ from __future__ import annotations import asyncio import logging +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any +from scraper_kit.orchestration import runs as kit_runs from scraper_kit.orchestration.scheduler import ( Handler, reschedule_after_minutes, @@ -39,39 +41,97 @@ logger = logging.getLogger(__name__) # ── cian_history_backfill — cookie-gated backfill ──────────────────────────── +# Машиночитаемые причины пропуска (#2658) — пишутся в scrape_runs.error строки +# со status='skipped'. Отделены от kit-причин (already_running и т.п.): по слагу +# видно, встал ли сбор из-за кук или из-за конкурентного прогона. +SKIP_CIAN_COOKIES_MISSING = "cian_cookies_missing" +SKIP_CIAN_COOKIES_EXPIRED = "cian_cookies_expired" +SKIP_CIAN_COOKIES_INVALID = "cian_cookies_invalid" + + +def _alert_cian_cookies(source: str, detail: str) -> None: + """Громкий алерт «сбор встал из-за кук» — logger.error, НЕ capture_message(warning). + + В scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR) + (scheduler_main.py) — ERROR-запись сама становится событием, а прежний + `capture_message(..., level="warning")` до этого уровня не дотягивал (и стоял в + недостижимой ветке, см. докстринг _cian_pre_claim). Заодно причина остаётся в + docker-логах и в строке scrape_runs, которая переживает редеплой. + """ + logger.error( + "scheduler: %s пропущен — %s. Перезалейте куки Циана через админку " + "(до этого backfill истории стоит)", + source, + detail, + ) + + async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool: """Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522). - Cookies отсутствуют/протухли → defer next_run_at на следующее окно и skip - (иначе get_due_schedules переотбирает schedule каждые 60с и verify_session - долбит Cian круглосуточно). Дословно из боевого trigger_cian_backfill_run. - """ - import sentry_sdk + Cookies отсутствуют/протухли → пишем строку прогона status='skipped' с причиной, + двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает + schedule каждые 60с и verify_session долбит Cian круглосуточно). - from app.services.cian_session import load_session, verify_session + #2658 — что было не так. Первая ветка (load_session вернул None) молчала: warning в + docker-лог, сдвиг next_run_at, `return False`. Ни строки в scrape_runs, ни изменения + last_run_at — снаружи 37 дней простоя выглядели как «всё по расписанию». Sentry-алерт + стоял во ВТОРОЙ ветке (verify_session вернул None), до которой на протухших куках + исполнение не доходит НИКОГДА: load_session сам фильтрует expires_at_estimate > NOW() + и отдаёт None ещё в первой. Теперь громко в обеих + предупреждение ЗАРАНЕЕ, пока куки + ещё валидны (COOKIE_EXPIRY_WARN_DAYS) — обновление кук ручное, ему нужен запас. + """ + from app.services.cian_session import ( + COOKIE_EXPIRY_WARN_DAYS, + load_session, + session_expires_at, + verify_session, + ) + + source: str = schedule_row["source"] + now = datetime.now(tz=UTC) cookies = load_session(db) if cookies is None: - logger.warning("scheduler: cian_history_backfill skipped — no valid session cookies in DB") + expires_at = session_expires_at(db) + if expires_at is None: + reason, detail = SKIP_CIAN_COOKIES_MISSING, "кук Циана нет в БД" + elif expires_at <= now: + reason = SKIP_CIAN_COOKIES_EXPIRED + detail = ( + f"куки Циана протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)" + ) + else: + reason = SKIP_CIAN_COOKIES_INVALID + detail = "куки Циана помечены невалидными (last_invalid_at)" + _alert_cian_cookies(source, detail) + kit_runs.mark_skipped(db, source=source, reason=reason, details=detail) kit_defer_next_run_at(db, schedule_row) return False state = await verify_session(cookies) if state is None: - logger.warning( - "scheduler: cian_history_backfill — cookies expired or invalid, skipping run" - ) - try: - sentry_sdk.capture_message( - "cian_history_backfill skipped: Cian session cookies expired — " - "please re-upload via admin UI", - level="warning", - ) - except Exception: - pass # sentry_sdk not initialised in dev + # verify вернул именно None (401 / isAuthenticated=false) — куки числятся + # валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник + # недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session. + detail = "Циан не принимает куки (разлогин)" + _alert_cian_cookies(source, detail) + kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail) kit_defer_next_run_at(db, schedule_row) return False + # Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора. + # valid_only=True: срок ИМЕННО той записи, которую взял load_session (при нескольких + # аккаунтах свежайшая-любая может быть чужой протухшей строкой). + expires_at = session_expires_at(db, valid_only=True) + if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS): + logger.error( + "scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, " + "иначе %s встанет молча", + expires_at.date().isoformat(), + (expires_at - now).total_seconds() / 86400, + source, + ) return True @@ -156,12 +216,26 @@ async def _job_deactivate_stale( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext ) -> None: from app.core.config import settings as _settings - from app.tasks.deactivate_stale_avito import deactivate_stale_listings + from app.tasks.deactivate_stale_avito import ( + DEFAULT_MIN_CONFIRMATIONS, + DEFAULT_REVISIT_FLOOR_QUANTILE, + deactivate_stale_listings, + ) listing_source: str = params.get("listing_source", "avito") ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days) segments: list[str] | None = params.get("segments") staleness_column: str = params.get("staleness_column", "last_seen_at") + # Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание + # получает страховочный порог, а не «деактивируй вслепую». Посчитанные по + # источнику пороги приходят из default_params (миграция 219). + min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS) + # Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию: + # незасеянное расписание не должно снимать объявления по порогу ниже собственного + # хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0. + revisit_floor_quantile: float = params.get( + "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE + ) loop = asyncio.get_event_loop() await loop.run_in_executor( @@ -173,6 +247,8 @@ async def _job_deactivate_stale( ttl_days=ttl_days, segments=segments, staleness_column=staleness_column, + min_confirmations=min_confirmations, + revisit_floor_quantile=revisit_floor_quantile, ), ) @@ -282,6 +358,19 @@ async def _job_domclick_detail_backfill( await run_domclick_detail_backfill(db, run_id=run_id, params=params) +# ── house_coords_from_listings — sync set-based UPDATE в executor (#2771) ───── +async def _job_house_coords_from_listings( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + from app.tasks.backfill_house_coords_from_listings import run_house_coords_from_listings + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: run_house_coords_from_listings(db, run_id=run_id, params=params), + ) + + # ── geoportal_coords_backfill — sync local exact match в executor (#1967) ───── async def _job_geoportal_coords_backfill( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext @@ -341,17 +430,38 @@ async def _job_house_imv_backfill( only_status=only_status, heartbeat=_heartbeat, ) - ctx.runs.mark_done( - db, - run_id, - { - "checked": result.checked, - "saved": result.saved, - "skipped": result.skipped, - "errors": result.errors, - "duration_sec": int(result.duration_sec), - }, - ) + counters = { + "checked": result.checked, + "saved": result.saved, + "skipped": result.skipped, + "errors": result.errors, + "duration_sec": int(result.duration_sec), + # #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из + # ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного + # из них тут не было, поэтому все 39 прогонов этого source лежат в БД + # с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд + # done с нулевым результатом» (#2625) — то есть даже идеальный прогон + # с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы + # ложной тревогой про капчу. + # Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст + # алерт — но пустая очередь при ежедневном расписании это и правда сигнал. + "total_seen": result.checked, + "new_count": result.saved, + } + # Честный статус (#2674, тот же класс, что #2670/#2657): успех — это + # «сделали то, что собирались», а не «не поймали известное исключение». + # На проде так ушли в done 31 прогон подряд: saved=0 при errors≈35 из 50. + # Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) — честная + # пустота, она по-прежнему done. + if result.saved == 0 and result.errors > 0: + ctx.runs.mark_failed( + db, + run_id, + f"saved=0 при errors={result.errors} (checked={result.checked})", + counters, + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception as exc: logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id) try: @@ -360,6 +470,75 @@ async def _job_house_imv_backfill( logger.exception("scheduler: mark_failed crashed run_id=%d", run_id) +# ── domrf_kapremont_load — sync загрузка open data ДОМ.РФ в executor ───────── +# #2674: loader (services/domrf_kapremont_loader.py) и CLI (tasks/domrf_kapremont_load.py) +# написаны и покрыты тестами с #2013, но Handler'а и строки расписания не было — источник +# запускали руками ровно один раз, 12.07.2026 (29 978 строк, один и тот же loaded_at у всех). +# Это не мёртвый код, а оборванная проводка: нечему было его вызвать. +async def _job_domrf_kapremont_load( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + """Скачать КР1.1+КР1.2 ДОМ.РФ → staging → backfill houses → propagate listings. + + Тело переиспользует те же три функции, что и CLI (дизайн-инвариант модуля: не + дублируем логику). Lifecycle не свой — mark_done/mark_failed здесь, как у + _job_yandex_newbuilding_sweep. + + Счётчики кладём в total_seen/new_count: `scrape_runs._column_counts` берёт выделенные + колонки именно из этих ключей, и по ним же mark_done ловит «три подряд нулевых + прогона» (#2625) — без них идеальный прогон лежал бы в БД как нулевой (тот же + промах, что чинили у house_imv_backfill). + """ + from app.services.domrf_kapremont_loader import ( + backfill_houses_from_domrf, + load_domrf_kapremont, + propagate_listings_year_from_houses, + ) + + def _run() -> dict[str, int]: + load_counts = load_domrf_kapremont(db) + db.commit() + houses_counts = backfill_houses_from_domrf(db) + listings_counts = propagate_listings_year_from_houses(db) + db.commit() + return { + "kr11_rows": load_counts["kr11_rows"], + "upserted": load_counts["upserted"], + "houses_updated": houses_counts["houses_updated"], + "listings_updated": listings_counts["listings_updated"], + # см. докстринг: выделенные колонки прогона + гейт «нулевой прогон». + "total_seen": load_counts["kr11_rows"], + "new_count": houses_counts["houses_updated"] + listings_counts["listings_updated"], + } + + loop = asyncio.get_event_loop() + try: + counters = await loop.run_in_executor(None, _run) + ctx.runs.mark_done(db, run_id, counters) + except Exception as exc: + logger.exception("scheduler: domrf_kapremont_load crashed run_id=%d", run_id) + db.rollback() + ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {}) + + +# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ─────────────── +async def _job_purge_expired_trade_in_data( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + from app.tasks.purge_expired_trade_in_data import purge_expired_trade_in_data + + batch_size = params.get("batch_size") + max_batches = params.get("max_batches") + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: purge_expired_trade_in_data( + db, run_id, batch_size=batch_size, max_batches=max_batches + ), + ) + + # ── house_dedup_merge — sync destructive merge в executor, owns lifecycle ───── async def _job_house_dedup_merge( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext @@ -394,8 +573,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: """Реестр НЕ-sweep продуктовых source→Handler для kit build_registry. Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь — - их даёт build_registry(_default_kit_handlers). Здесь — 19 именованных + 1 wildcard + их даёт build_registry(_default_kit_handlers). Здесь — именованные + 1 wildcard (deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch. + (Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.) `ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается. @@ -427,6 +607,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "geoportal_coords_backfill": Handler( _job_geoportal_coords_backfill, "geoportal_coords_backfill" ), + "house_coords_from_listings": Handler( + _job_house_coords_from_listings, "house_coords_from_listings" + ), "geocode_missing_listings": Handler( _job_geocode_missing_listings, "geocode_missing_listings" ), @@ -439,6 +622,10 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"), "house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"), "house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"), + "domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"), + "purge_expired_trade_in_data": Handler( + _job_purge_expired_trade_in_data, "purge_expired_trade_in_data" + ), "proxy_healthcheck": Handler( _job_proxy_healthcheck, "proxy_healthcheck", diff --git a/tradein-mvp/backend/app/services/proxy_egress.py b/tradein-mvp/backend/app/services/proxy_egress.py new file mode 100644 index 00000000..c28426a4 --- /dev/null +++ b/tradein-mvp/backend/app/services/proxy_egress.py @@ -0,0 +1,301 @@ +"""Резолвер egress-прокси по источнику для ad-hoc сессий вне scrape_run (#2825). + +ПРОБЛЕМА (доказана на проде 2026-08-10): `settings.scraper_proxy_url` (и его алиасы +`cian_proxy_url`/`yandex_proxy_url`, все три — прямая проекция ENV `SCRAPER_PROXY_URL`, +см. `app.core.config`) был ЕДИНСТВЕННЫМ egress для всех curl_cffi/httpx-сессий, которые +строятся напрямую в `app/services/*` и `app/tasks/*` МИМО `app.services.proxy_pool` / +`scraper_kit`-оркестрации. При этом `scrape_proxy_source_bans` (миграция 210, #2600 п.2) +аккуратно вела учёт банов по паре «узел × источник» — но эти прямые сессии её никогда +не читали и месяц ходили через узел, забаненный и Avito, и Cian. + +ЧТО ЭТОТ МОДУЛЬ НЕ ДЕЛАЕТ: не берёт lease. `app.services.proxy_pool.acquire()` уже +реализует pick-с-учётом-банов, но с полной lease-семантикой (leased_by/release/ +reap_stale_leases) — она рассчитана на долгоживущие `scrape_run`/`BrowserFetcher`-сессии +(см. `RealProxyProvider` в `app.services.scraper_adapters`). Вызывающие здесь — короткие +одноразовые fetch'и (проверка cookies, одна detail-страница) без run_id и без +гарантированного `release` на каждом пути выхода; занимать под них lease значило бы +дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже — +ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок +и без мутаций. + +ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS +(тот же карантинный порог, что у acquire), нет активной строки в +scrape_proxy_source_bans для ЭТОГО source. Среди кандидатов — меньший consecutive_fails, +при равенстве — более свежий last_ok_at (NULLS LAST). Не изобретаем ротацию/балансировку: +это резолвер «дай рабочий прокси прямо сейчас», не lease-менеджер. + +FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный +`SCRAPER_PROXY_URL` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет": + + 1. Пул ПУСТ (в `scrape_proxies` вообще нет строк — dev/staging без БД-пула, легитимный + сценарий). Тогда fallback на `settings.scraper_proxy_url` ЛЕГИТИМЕН — пула для этого + окружения попросту не существует, идти больше некуда. `logger.warning`. + 2. Пул НЕ пуст, но НИ ОДИН узел не прошёл фильтр для source (все забанены ИМЕННО для + этого источника / нездоровы / выключены). Здесь fallback на `SCRAPER_PROXY_URL` + ЗАПРЕЩЁН: инцидент 2026-08-10 — это ровно случай (2), узел статичной переменной был + тем же самым забаненным узлом, что и в пуле, «резервный» путь тихо возвращал систему + к первопричине. `resolve_proxy_url` в этом случае бросает `ProxyPoolExhaustedError` — + вызывающий обязан явно отказаться от запроса (`logger.error`), а не соскользнуть на + env в обход учёта банов. + +НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url +несёт логин/пароль, в лог никогда не идёт целиком) и id узла; при legit-fallback — +warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой +banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы +их нельзя было спутать в логах/алертах. + +psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from urllib.parse import urlsplit + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings as _settings +from app.core.db import SessionLocal as _SessionLocal +from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS + +logger = logging.getLogger(__name__) + +__all__ = ["ProxyPoolExhaustedError", "resolve_proxy_url", "resolve_proxy_url_sync"] + + +class ProxyPoolExhaustedError(RuntimeError): + """Пул `scrape_proxies` НЕ пуст, но ни один узел не прошёл фильтр для `source` + (все забанены именно для этого источника / нездоровы / выключены). + + Fail-closed (#2616): вызывающий обязан явно отказаться от запроса (пропустить run + с понятным логом), а НЕ уйти в обход пула через статичный + `settings.scraper_proxy_url` — тот самый узел мог быть источником текущего + инцидента (см. module docstring, сценарий 2). + """ + + def __init__( + self, + source: str, + *, + pool_total: int, + banned_for_source: int, + unhealthy_or_disabled: int, + ) -> None: + self.source = source + self.pool_total = pool_total + self.banned_for_source = banned_for_source + self.unhealthy_or_disabled = unhealthy_or_disabled + super().__init__( + f"proxy pool exhausted for source={source!r}: pool_total={pool_total} " + f"banned_for_source={banned_for_source} unhealthy_or_disabled={unhealthy_or_disabled}" + ) + + +@dataclass(frozen=True) +class _Candidate: + id: int + url: str + label: str | None + + +def _safe_label(proxy_id: int, label: str | None, url: str) -> str: + """host:port для логов — НИКОГДА не credentials из url (userinfo).""" + if label: + return label + try: + parts = urlsplit(url) + host = parts.hostname or "?" + return f"{host}:{parts.port}" if parts.port else host + except ValueError: + return f"proxy#{proxy_id}" + + +def _pick_candidate(db: Session, source: str) -> _Candidate | None: + """READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел.""" + 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 + LIMIT 1 + """ + ), + {"max_fails": MAX_CONSECUTIVE_FAILS, "source": source}, + ) + .mappings() + .fetchone() + ) + # Чистое чтение без блокировок — ничего не коммитим/не откатываем намеренно, + # оставляем управление транзакцией вызывающему коду (тот же db может быть в + # середине более широкой операции). + if row is None: + return None + return _Candidate(id=int(row["id"]), url=str(row["url"]), label=row["label"]) + + +@dataclass(frozen=True) +class _ExhaustionDiag: + """Разбивка причин "кандидата нет" — ТОЛЬКО когда пул реально не пуст (сценарий 2 + в докстринге модуля). Используется исключительно для diagnostic-лога/исключения.""" + + pool_total: int + banned_for_source: int + unhealthy_or_disabled: int + + +def _diagnose_no_candidate(db: Session, source: str) -> _ExhaustionDiag: + """Отдельный запрос, вызывается ТОЛЬКО когда основной SELECT кандидата вернул + пусто — не платим за агрегаты в happy-path (кандидат найден с первого запроса).""" + row = ( + db.execute( + text( + """ + SELECT + count(*) AS pool_total, + count(*) FILTER ( + WHERE NOT enabled + OR consecutive_fails >= CAST(:max_fails AS integer) + ) AS unhealthy_or_disabled, + count(*) FILTER ( + WHERE enabled + AND consecutive_fails < CAST(:max_fails AS integer) + AND EXISTS ( + SELECT 1 + FROM scrape_proxy_source_bans b + WHERE b.proxy_id = scrape_proxies.id + AND b.source = CAST(:source AS text) + AND b.banned_until > now() + ) + ) AS banned_for_source + FROM scrape_proxies + """ + ), + {"max_fails": MAX_CONSECUTIVE_FAILS, "source": source}, + ) + .mappings() + .fetchone() + ) + if row is None: # pragma: no cover — count(*) всегда возвращает строку + return _ExhaustionDiag(pool_total=0, banned_for_source=0, unhealthy_or_disabled=0) + return _ExhaustionDiag( + pool_total=int(row["pool_total"]), + banned_for_source=int(row["banned_for_source"]), + unhealthy_or_disabled=int(row["unhealthy_or_disabled"]), + ) + + +def resolve_proxy_url(db: Session, source: str) -> str | None: + """Egress-URL для source (avito/cian/yandex/domclick) — пул с учётом банов пары + «узел × источник». См. докстринг модуля за разбором двух РАЗНЫХ исходов + "кандидата нет": + + - пул пуст (0 строк в `scrape_proxies`) → fallback на + `settings.scraper_proxy_url`, `logger.warning`, легитимный dev/staging-сценарий; + - пул не пуст, все отсеяны (баны/health/disabled) → `ProxyPoolExhaustedError` + (`logger.error`), fail-closed — БЕЗ прохода через статичный env. + + БД пула недоступна (connection error и т.п., напр. dev-окружение без поднятой БД) + — трактуем КАК пустой пул (не можем подтвердить exhaustion — небезопасно поднимать + error/исключение по неполным данным), `logger.warning` + explicit (не silent + failure). Отличается от сценария exhaustion: там мы ТОЧНО знаем, что узлы есть и + все отсеяны; здесь мы вообще ничего не знаем о пуле. + """ + try: + candidate = _pick_candidate(db, source) + except Exception: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies недоступен (ошибка БД), " + "лечим как пустой пул (fallback на статичный SCRAPER_PROXY_URL)", + source, + exc_info=True, + ) + try: + # Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG: + # "current transaction is aborted") — если db переживёт этот вызов + # (долгоживущая caller-сессия, напр. avito_detail_backfill/ + # yandex_detail_backfill), последующие запросы на ней иначе все падали + # бы с той же ошибкой, маскируя реальную причину. + db.rollback() + except Exception: + logger.warning( + "proxy_egress: source=%s -- rollback после сбоя пула тоже не удался", + source, + exc_info=True, + ) + return _settings.scraper_proxy_url + + if candidate is not None: + logger.info( + "proxy_egress: source=%s -> pool proxy id=%d (%s)", + source, + candidate.id, + _safe_label(candidate.id, candidate.label, candidate.url), + ) + return candidate.url + + diag = _diagnose_no_candidate(db, source) + + if diag.pool_total == 0: + # Сценарий 1: пул для этого окружения попросту не сконфигурирован + # (dev/staging без БД-пула) — легитимный fallback. + fallback = _settings.scraper_proxy_url + if fallback: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies ПУСТ (0 записей), " + "окружение без БД-пула -- идём через статичный SCRAPER_PROXY_URL " + "(fallback)", + source, + ) + else: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies пуст и SCRAPER_PROXY_URL " + "не задан, идём прямым подключением без прокси", + source, + ) + return fallback + + # Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного + # здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env. + logger.error( + "proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН " + "не прошёл фильтр для этого источника (banned_for_source=%d, " + "unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через " + "статичный SCRAPER_PROXY_URL (тот самый узел мог быть источником инцидента)", + source, + diag.pool_total, + diag.banned_for_source, + diag.unhealthy_or_disabled, + ) + raise ProxyPoolExhaustedError( + source, + pool_total=diag.pool_total, + banned_for_source=diag.banned_for_source, + unhealthy_or_disabled=diag.unhealthy_or_disabled, + ) + + +def resolve_proxy_url_sync(source: str) -> str | None: + """Как `resolve_proxy_url`, но сама открывает короткую `SessionLocal()` — для + вызывающих без готового `db` в сигнатуре (напр. `cian_session.verify_session`). + + `ProxyPoolExhaustedError` из `resolve_proxy_url` пробрасывается как есть (fail-closed) — + вызывающий обязан явно её поймать и решить, как деградировать (см. call site'ы). + """ + db = _SessionLocal() + try: + return resolve_proxy_url(db, source) + finally: + db.close() diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 0e7e503d..8ed2176e 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -77,6 +77,42 @@ Sticky session lease (browser-путь, живая регрессия 2026-08): на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового прогона. +Два тракта — два диагноза (#2723): + - ipify-проба (`_probe_proxy`) отвечает на «узел жив вообще» и владеет + consecutive_fails / enabled / exit_ip. Такт — каждый прогон healthcheck (30 мин). + - браузерная проба (`_run_browser_probe` → сайдкар → camoufox с ЭТИМ прокси → + навигация) отвечает на «через узел работает браузерный тракт» и владеет + browser_fail_streak / browser_unfit_since / browser_check_at (миграция 228). + Такт свой, редкий (BROWSER_PROBE_MINUTES) — она стоит запуска camoufox. + Пересечения нет: успешная ipify-проба НЕ обнуляет browser_fail_streak (иначе + дешёвая проба каждые 30 минут стирает вердикт дорогого тракта — узел, мёртвый для + браузера, вечно возвращается в выдачу), провал браузерной пробы НЕ выключает узел + (он жив, просто не для этого тракта). Схлопнуть их в один флаг = повторить #2686. + «Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой + узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже. + +Проба на ПАРУ «узел × источник» (#2800, продолжение #2723): + - #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом + для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв» + вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500 + NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и + свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен». + - Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может + достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки + (apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а + `bff-search-web.domclick.ru`, куда ходит сбор, — 500). + - Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой + сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый + отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки. + Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt + площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать + дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»). + - Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение + «браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО + креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла. + Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками + вернули то самое схлопывание диагнозов. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -90,9 +126,13 @@ import httpx from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings as _settings + logger = logging.getLogger(__name__) __all__ = [ + "BROWSER_PROBE_MINUTES", + "BROWSER_UNFIT_THRESHOLD", "DISABLED_RECHECK_MINUTES", "DISABLE_THRESHOLD", "MAX_CONSECUTIVE_FAILS", @@ -105,7 +145,9 @@ __all__ = [ "acquire", "clear_source_bans", "mark_banned", + "mark_browser_health", "mark_health", + "mark_source_probe", "reap_stale_leases", "release", "run_proxy_healthcheck", @@ -155,6 +197,47 @@ SOURCE_BAN_PURGE_DAYS = 7 _HEALTH_PROBE_URL = "https://api.ipify.org" _HEALTH_PROBE_TIMEOUT_S = 10.0 +# ── браузерная проба узла (#2723) ──────────────────────────────────────────── +# Такт браузерной пробы. Решено по замеру, не по ощущению (прод, 06.08.2026): +# - одна браузерная проба = 8.3с и один запуск camoufox; +# - боевая нагрузка сайдкара = ~42 /fetch и ~8 запусков camoufox в час +# (≈1000 и ≈190 в сутки); +# - такт ipify-пробы = 30 мин → 48 прогонов healthcheck в сутки. +# Гнать браузерную пробу каждым прогоном по 4 узлам = +192 запуска camoufox в сутки, +# то есть УДВОЕНИЕ самой дорогой операции сайдкара ради диагностики. 360 мин даёт +# 4 пробы на узел в сутки: +16 запусков (+8% к запускам, +1.6% к запросам) — цена, +# которую видно только в логе. Отказ, пойманный с задержкой до 6 часов, всё равно +# ловится в разы раньше, чем сейчас (не ловится вовсе). +BROWSER_PROBE_MINUTES = 360 + +# Столько подряд-провалов браузерной пробы (атрибутированных узлу) переводят узел в +# browser_unfit. Не 1: запуск camoufox бывает флаки сам по себе, а пометка — операция +# с последствиями при пуле из 4 узлов. Не 5 (как DISABLE_THRESHOLD): при редком такте +# это были бы сутки. Второе подтверждение приходит на СЛЕДУЮЩЕМ прогоне healthcheck +# (~30 мин), а не через полный такт — browser_check_at на неподтверждённом провале +# намеренно не обновляется (см. mark_browser_health). +BROWSER_UNFIT_THRESHOLD = 2 + +# ── проба на пару «узел × источник» (#2800) ────────────────────────────────── +# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт): +# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито; +# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть +# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch; +# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов +# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at). +# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при +# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было. +# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и +# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором +# acquire снова выдаёт узел, не спросив. +# +# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её +# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная +# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка +# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка +# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре. +_PROBE_BAN_REASON = "probe:browser" + # deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для # mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что # в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не @@ -188,14 +271,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах чужой affinity (#2600). - Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см. - 173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1), - намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого - одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex, - domclick останется без прокси вообще — хуже, чем голодание исходного источника, - которое фикс призван устранить. Кандидат участвует в fallback, только если его - affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) — - т.е. выдача не обнулит доступность выделенной affinity целиком. + Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если + fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже, + чем голодание исходного источника, которое фикс призван устранить. Кандидат + участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ + enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной + affinity целиком. + + Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql — + единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR + банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до + рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а + Авито/Яндекс через него работают — резервация держала узел за источником, которому + он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде + сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и + следующий выделенный узел должен получить её сразу, а не после повторного разбора. ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у (scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито, @@ -213,7 +303,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe db.execute( text( """ - SELECT id, url, kind, rotate_url + SELECT id, url, kind, rotate_url, browser_unfit_since FROM scrape_proxies WHERE enabled AND consecutive_fails < CAST(:max_fails AS integer) @@ -226,7 +316,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe AND b.source = :provider AND b.banned_until > now() ) - ORDER BY last_ok_at NULLS LAST, id + -- browser_unfit последним (#2723): узел, живой для HTTP, но не для + -- браузера, из пула НЕ исключается — только уходит в конец очереди. + ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id FOR UPDATE SKIP LOCKED LIMIT 1 """ @@ -247,7 +339,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe db.execute( text( """ - SELECT sp.id, sp.url, sp.kind, sp.rotate_url + SELECT sp.id, sp.url, sp.kind, sp.rotate_url, sp.browser_unfit_since FROM scrape_proxies AS sp WHERE sp.enabled AND sp.consecutive_fails < CAST(:max_fails AS integer) @@ -283,7 +375,8 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe ) ) ) - ORDER BY sp.last_ok_at NULLS LAST, sp.id + -- см. ORDER BY основного запроса (#2723) + ORDER BY (sp.browser_unfit_since IS NOT NULL), sp.last_ok_at NULLS LAST, sp.id FOR UPDATE SKIP LOCKED LIMIT 1 """ @@ -323,6 +416,19 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe logger.info( "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker ) + if row["browser_unfit_since"] is not None: + # Узел помечен непригодным для браузера (#2723), но всё равно выдан — значит + # пригодных свободных не осталось. Голодание хуже работы через плохой узел + # (та же политика, что у защиты последнего узла в mark_banned), но молчать об + # этом нельзя: для браузерного источника это заведомо обречённый прогон. + logger.warning( + "proxy_pool: leased proxy id=%d provider=%s — узел BROWSER-UNFIT с %s " + "(жив для HTTP, браузерный тракт через него не работает). Выдан потому, " + "что пригодных свободных узлов нет — пул надо пополнять (#2638).", + proxy_id, + provider, + row["browser_unfit_since"], + ) return ProxyLease( id=proxy_id, url=str(row["url"]), @@ -480,9 +586,182 @@ def mark_health( ) -def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: +def mark_browser_health( + db: Session, + proxy_id: int, + ok: bool, + *, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать результат БРАУЗЕРНОЙ пробы узла (#2723). Returns исход для счётчиков. + + ЧЕМ ОТЛИЧАЕТСЯ ОТ mark_health: тем же, чем «нас забанила площадка» отличается от + «у нас упал сайдкар» (#2686/#2711) — это ДРУГОЙ диагноз, а не другое значение того + же. mark_health отвечает на «узел жив вообще» и владеет + consecutive_fails/enabled/exit_ip. Эта функция отвечает на «через узел работает + браузерный тракт» и владеет browser_fail_streak/browser_unfit_since/ + browser_check_at. Пересечения нет НИ В ОДНУ сторону, и это главное: + + - успешная ipify-проба НЕ обнуляет browser_fail_streak. До #2723 обнуляла бы + (через consecutive_fails=0) — узел, мёртвый для браузера, выходил из карантина + каждые ≤30 минут и снова забирал прогон; + - провал браузерной пробы НЕ инкрементит consecutive_fails и НЕ выключает узел: + он жив, просто не для этого тракта. + + ЧТО СЧИТАЕТСЯ ПРОВАЛОМ УЗЛА: только fail_kind == "proxy" (см. + scraper_kit.browser_fetcher.classify_browser_probe). "sidecar" (сайдкар лежит) и + "page" (площадка отдала пустое) узлу не принадлежат — засчитывать их значило бы + пометить непригодными ВСЕ узлы разом при одной упавшей общей зависимости, то есть + повторить #2686 ещё раз и уже с последствиями для всего пула. + + ТАКТ ПРИ ПРОВАЛЕ: browser_check_at обновляется только когда провал ПОДТВЕРЖДЁН + (streak дошёл до BROWSER_UNFIT_THRESHOLD). На первом, ещё не подтверждённом + провале поле остаётся старым → следующий же прогон healthcheck (~30 мин) повторит + пробу и либо подтвердит отказ, либо снимет подозрение. Иначе подтверждения ждали бы + полный BROWSER_PROBE_MINUTES. + + Returns: "ok" | "refit" (узел был непригоден и починился) | "unfit" (только что + помечен непригодным) | "fail" (провал засчитан, порог не достигнут) | "ignored" + (провал не принадлежит узлу). + """ + if ok: + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies AS sp + SET browser_fail_streak = 0, + browser_unfit_since = NULL, + browser_check_at = now(), + updated_at = now() + -- prev — pre-image строки: RETURNING отдаёт УЖЕ обновлённые + -- значения (browser_unfit_since там всегда NULL), а нам нужно + -- знать, была ли это реанимация непригодного узла. + FROM ( + SELECT id, browser_unfit_since + FROM scrape_proxies + WHERE id = CAST(:id AS bigint) + ) AS prev + WHERE sp.id = prev.id + RETURNING (prev.browser_unfit_since IS NOT NULL) AS was_unfit + """ + ), + {"id": proxy_id}, + ) + .mappings() + .fetchone() + ) + db.commit() + was_unfit = bool(row["was_unfit"]) if row is not None else False + logger.info( + "proxy_pool: browser probe OK id=%d (%s)%s", + proxy_id, + detail, + " — узел снова пригоден для браузера" if was_unfit else "", + ) + return "refit" if was_unfit else "ok" + + if fail_kind != "proxy": + logger.warning( + "proxy_pool: browser probe FAILED id=%d, но отказ НЕ принадлежит узлу " + "(fail_kind=%s): %s — browser_fail_streak не трогаем", + proxy_id, + fail_kind, + detail, + ) + return "ignored" + + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies + SET browser_fail_streak = browser_fail_streak + 1, + browser_unfit_since = CASE + WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer) + AND browser_unfit_since IS NULL + THEN now() ELSE browser_unfit_since + END, + browser_check_at = CASE + WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer) + THEN now() ELSE browser_check_at + END, + updated_at = now() + WHERE id = CAST(:id AS bigint) + RETURNING browser_fail_streak, browser_unfit_since + """ + ), + {"threshold": BROWSER_UNFIT_THRESHOLD, "id": proxy_id}, + ) + .mappings() + .fetchone() + ) + db.commit() + if row is None: + logger.warning("proxy_pool: mark_browser_health id=%d not found — no-op", proxy_id) + return "ignored" + + streak = int(row["browser_fail_streak"]) + if streak >= BROWSER_UNFIT_THRESHOLD: + logger.warning( + "proxy_pool: proxy id=%d BROWSER-UNFIT (browser_fail_streak=%d) — жив для " + "обычного HTTP, но браузерный тракт через него не работает: %s. Узел " + "ОСТАЁТСЯ в пуле (enabled не тронут, curl-путь работает), но acquire() " + "теперь отдаёт его последним (#2723).", + proxy_id, + streak, + detail, + ) + return "unfit" + logger.warning( + "proxy_pool: browser probe FAILED id=%d (browser_fail_streak=%d/%d, порог не " + "достигнут — перепроверим на следующем прогоне): %s", + proxy_id, + streak, + BROWSER_UNFIT_THRESHOLD, + detail, + ) + return "fail" + + +def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары + принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего + узла) | "missing" (нет такого proxy_id). + + `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по + умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы — + _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому + clear_source_bans умеет фильтровать по ней (`only_reason`). + + ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026: + пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через + ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр + «снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ: + присвоенная строка становится «своей», и следующая успешная проба снимает ею бан, + который поставил боевой сбор по настоящему отказу площадки. Плюс теряется + происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба + не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count + начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых + пары с 6 ч до 12 ч). + + Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет), + ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`). + Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно» + только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен + УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего. + + АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее + (площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится + ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный + robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально + и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного); + обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об + эскалации боевых банов пары. + Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация — транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно @@ -541,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает (падает как обычно) — caller решает, ловить или нет. """ + # Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё + # (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ"). + live_reason = f"banned:{source}" + effective_reason = reason or live_reason # Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг # "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции. db.execute( @@ -612,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: ) AS integer)), reason = CAST(:reason AS text), updated_at = now() + -- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет), + -- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее + -- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803). + WHERE scrape_proxy_source_bans.banned_until <= now() + OR scrape_proxy_source_bans.reason = CAST(:reason AS text) + OR CAST(:reason AS text) = CAST(:live_reason AS text) RETURNING ban_count, banned_until """ ), { "proxy_id": proxy_id, "source": source, - "reason": f"banned:{source}", + "reason": effective_reason, + "live_reason": live_reason, "base_hours": SOURCE_BAN_BASE_HOURS, "max_hours": SOURCE_BAN_MAX_HOURS, "max_fails": MAX_CONSECUTIVE_FAILS, @@ -638,15 +928,44 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: row["banned_until"], row["ban_count"], ) - return + return "banned" + + # 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита + # последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога + # (на решение уже не влияет), но диагноз должен называть то, что произошло. + holder = ( + db.execute( + text( + """ + SELECT reason, banned_until + FROM scrape_proxy_source_bans + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND source = CAST(:source AS text) + AND banned_until > now() + """ + ), + {"proxy_id": proxy_id, "source": source}, + ) + .mappings() + .fetchone() + ) + if holder is not None and holder["reason"] != effective_reason: + logger.info( + "proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт " + "%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, " + "иначе проба присвоила бы чужой бан и потом сняла бы его как свой)", + proxy_id, + source, + holder["reason"], + holder["banned_until"], + effective_reason, + ) + return "deferred" - # 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем - # текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет). current = ( db.execute( text( - "SELECT enabled, disabled_reason FROM scrape_proxies " - "WHERE id = CAST(:id AS bigint)" + "SELECT enabled, disabled_reason FROM scrape_proxies WHERE id = CAST(:id AS bigint)" ), {"id": proxy_id}, ) @@ -655,17 +974,25 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: ) if current is None: logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id) - else: - logger.warning( - "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " - "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " - "источнику (голодание хуже, чем работа через забаненный узел).", - proxy_id, - source, - ) + return "missing" + logger.warning( + "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " + "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " + "источнику (голодание хуже, чем работа через забаненный узел).", + proxy_id, + source, + ) + return "protected" -def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int: +def clear_source_bans( + db: Session, + proxy_id: int, + *, + source: str | None = None, + reason: str, + only_reason: str | None = None, +) -> int: """Снять баны узла по источникам (#2600 п.2). Returns число снятых строк. ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через @@ -688,6 +1015,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, SOURCE_BAN_BASE_HOURS. `reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»). + + `only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые + написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое + свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан, + распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация + IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной + целиком. None — снимать всё, как и раньше. """ rows = db.execute( text( @@ -695,10 +1029,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, DELETE FROM scrape_proxy_source_bans WHERE proxy_id = CAST(:proxy_id AS bigint) AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text)) + AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text)) RETURNING source """ ), - {"proxy_id": proxy_id, "source": source}, + {"proxy_id": proxy_id, "source": source, "only_reason": only_reason}, ).fetchall() db.commit() if rows: @@ -712,6 +1047,79 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, return len(rows) +def mark_source_probe( + db: Session, + proxy_id: int, + *, + source: str, + ok: bool, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800). + + Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или + не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает + `acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим. + + КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница + другая — здесь судится ПАРА, а не узел): + - "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored". + Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз); + - "proxy" — через этот узел до площадки не доходит транспорт + (NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары; + - "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку + (200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары. + Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для + ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя. + + Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный + боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить + дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная + половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не + присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь + присвоенная строка уже «своя». + + Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего + своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не + пишется (голодание хуже работы через плохой узел). + + Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned` + ("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном + только реально записанный бан. + """ + if ok: + cleared = clear_source_bans( + db, + proxy_id, + source=source, + reason=f"browser probe OK for source={source} ({detail})", + only_reason=_PROBE_BAN_REASON, + ) + return "cleared" if cleared else "ok" + + if fail_kind not in ("proxy", "page"): + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре " + "(fail_kind=%s): %s — вердикт не пишем", + proxy_id, + source, + fail_kind, + detail, + ) + return "ignored" + + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан " + "пары, узел остаётся первосортным для остальных площадок (#2800)", + proxy_id, + source, + fail_kind, + detail, + ) + return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) + + def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: """Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release). @@ -775,6 +1183,102 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No return False, None, None, "other" +def _probe_sources_for(affinity: str) -> list[str]: + """Источники, которым узел с такой affinity МОЖЕТ достаться (#2800). + + Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`. + Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику, + которой никто не воспользуется. + + ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих + свободных нет) — такая пара останется без вердикта и решится как раньше, по факту + прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же + только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос + бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4. + """ + from scraper_kit.browser_fetcher import PROBE_SOURCES + + return [s for s in PROBE_SOURCES if affinity in (s, "any")] + + +async def _run_pair_probes( + db: Session, proxy_id: int, url: str, kind: str, affinity: str +) -> tuple[str, dict[str, int]]: + """Крест «этот узел × каждая его площадка» + запись вердиктов (#2800). + + Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам). + + Два уровня вердикта, и они не пересекаются: + - ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке + отдельно, это то, что читает `acquire(source)`; + - УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по + итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел + работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки + узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов + появилось бы новое. + + Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет + healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не + имеет права ломать то, что диагностирует. + """ + from scraper_kit.browser_fetcher import probe_proxy_via_browser + + counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0} + fail_kinds: list[str] = [] + any_ok = False + last_detail = "" + + for source in _probe_sources_for(affinity): + try: + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + if not ok and fail_kind == "proxy": + # Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает + # флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор + # идёт по уже поднятому браузеру с тем же прокси — секунды, и только + # на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью + # (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания. + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + except Exception: + logger.warning( + "proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан", + proxy_id, + source, + exc_info=True, + ) + continue + + counters["pair_checked"] += 1 + last_detail = detail + if ok: + any_ok = True + else: + fail_kinds.append(fail_kind or "other") + outcome = mark_source_probe( + db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail + ) + if outcome == "banned": + counters["pair_banned"] += 1 + elif outcome == "cleared": + counters["pair_cleared"] += 1 + + if counters["pair_checked"] == 0: + return "ignored", counters # крест не состоялся — узел не судим + + if any_ok: + return mark_browser_health(db, proxy_id, True, detail=last_detail), counters + # Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ — + # транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле». + node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0] + return ( + mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail), + counters, + ) + + def _mask(url: str) -> str: """Скрыть пароль в proxy-url для логов (scheme://user:***@host).""" if "@" not in url or "//" not in url: @@ -808,9 +1312,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад (см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count). + БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не + проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ + САЙДКАР — по одной навигации на каждую площадку, которую этот узел может + обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси, + потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в + scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные + browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт — + settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и + проба измеряла бы то, чем никто не пользуется. + Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters - {reaped, checked, ok, failed, revived, bans_purged}. + {reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok, + browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}. """ reaped = reap_stale_leases(db) @@ -818,7 +1333,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind, enabled, disabled_reason + SELECT id, url, kind, enabled, disabled_reason, provider_affinity, + (browser_check_at IS NULL + OR browser_check_at < now() - make_interval( + mins => CAST(:browser_probe_minutes AS integer) + )) AS browser_probe_due FROM scrape_proxies WHERE enabled OR last_check_at IS NULL @@ -828,7 +1347,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: ORDER BY id """ ), - {"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES}, + { + "disabled_recheck_minutes": DISABLED_RECHECK_MINUTES, + "browser_probe_minutes": BROWSER_PROBE_MINUTES, + }, ) .mappings() .all() @@ -838,6 +1360,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: ok_count = 0 failed = 0 revived = 0 + browser_checked = 0 + browser_ok = 0 + browser_unfit = 0 + browser_refit = 0 + pair_checked = 0 + pair_banned = 0 + pair_cleared = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) @@ -860,6 +1389,27 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: else: failed += 1 + # Браузерная проба (#2723) — только если ipify прошла: провалившая ipify нода + # мертва целиком, диагноз уже поставлен, а запуск camoufox через неё — чистая + # трата 8 секунд. Гейт по use_proxy_pool_browser: при выключенном флаге браузер + # ходит мимо пула (через env-прокси сайдкара), и вердикт об узлах пула был бы + # вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет + # не тот узел», из-за которого #2723 и появилась. + if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser: + outcome, pair_counters = await _run_pair_probes( + db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"]) + ) + browser_checked += 1 + pair_checked += pair_counters["pair_checked"] + pair_banned += pair_counters["pair_banned"] + pair_cleared += pair_counters["pair_cleared"] + if outcome in ("ok", "refit"): + browser_ok += 1 + if outcome == "refit": + browser_refit += 1 + elif outcome == "unfit": + browser_unfit += 1 + # Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS, # НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует # (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации. @@ -882,13 +1432,21 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: logger.info( "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d " - "bans_purged=%d", + "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d " + "pair_checked=%d pair_banned=%d pair_cleared=%d", reaped, checked, ok_count, failed, revived, purged, + browser_checked, + browser_ok, + browser_unfit, + browser_refit, + pair_checked, + pair_banned, + pair_cleared, ) return { "reaped": reaped, @@ -897,4 +1455,16 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: "failed": failed, "revived": revived, "bans_purged": purged, + # Счётчики браузерной пробы (#2723) — намеренно ОТДЕЛЬНЫЕ от checked/ok/failed: + # схлопнув их в общие, мы бы своими руками сделали то, за что чиним этот модуль. + "browser_checked": browser_checked, + "browser_ok": browser_ok, + "browser_unfit": browser_unfit, + "browser_refit": browser_refit, + # Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и + # pair_banned=2 одновременно — это не противоречие, а точный диагноз + # «браузер через узел работает, но две площадки его не пускают». + "pair_checked": pair_checked, + "pair_banned": pair_banned, + "pair_cleared": pair_cleared, } diff --git a/tradein-mvp/backend/app/services/rosreestr_poll.py b/tradein-mvp/backend/app/services/rosreestr_poll.py index 1ef1d8df..3d278acd 100644 --- a/tradein-mvp/backend/app/services/rosreestr_poll.py +++ b/tradein-mvp/backend/app/services/rosreestr_poll.py @@ -50,9 +50,23 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн, что DEFAULT_UA в zhkh_flats_loader.py). -При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем -available=False. Отсутствие папки/файла квартала → available=False (штатный -случай до публикации квартала, до начала следующего месяца после конца квартала). +УРОВНИ СИГНАЛОВ (#2674 — в контейнере скрапера событием GlitchTip становится только +запись ERROR, см. scheduler_main.py LoggingIntegration(event_level=ERROR)): + - Портал ответил не-200 на листинг каталога/папки → ERROR. Каталог — единственная + опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча + врал целыми кварталами. Такое обязано быть событием. + - Файл датасета НАЙДЕН в листинге, но HEAD не отдал zip / размер ниже порога → + ERROR. Тот же класс: это ровно поведение старой Bitrix-заглушки (200 + text/html). + Ветка может сработать легитимно (файл выложили в листинг раньше, чем докачали), + но цена асимметрична — ложное срабатывание стоит одного события в месяц (такт + 28 дней), пропуск стоит квартала молчания. + - Таймаут / сетевая ошибка → WARNING, как раньше. Это транспортный блип раз в месяц + (такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный + deals_freshness_monitor ERROR-ом по max(deal_date). + - Папки/файла квартала нет → INFO. Штатное состояние до публикации: квартал выходит + 4 раза в год, поллер ходит 12 — большинство прогонов ЗАКОННО пустые. + - Квартал вышел → INFO + ЯВНОЕ событие capture_message(level="info"), см. + poll_rosreestr_new_quarter. """ from __future__ import annotations @@ -63,6 +77,7 @@ from typing import Any from urllib.parse import quote, unquote, urljoin import httpx +import sentry_sdk from sqlalchemy import text from sqlalchemy.orm import Session @@ -243,7 +258,8 @@ async def check_new_quarter_available( try: index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True) if index_resp.status_code != 200: - logger.warning( + # ERROR (#2674): без каталога поллер слеп — см. "УРОВНИ СИГНАЛОВ". + logger.error( "rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable", index_resp.status_code, _DATA_SETS_BASE_URL, @@ -265,7 +281,9 @@ async def check_new_quarter_available( folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href) folder_resp = await client.get(folder_url, follow_redirects=True) if folder_resp.status_code != 200: - logger.warning( + # ERROR (#2674): папка квартала НАЙДЕНА в каталоге, но не открывается — + # это уже не «ещё не опубликовали», а поломка портала. + logger.error( "rosreestr_poll: unexpected HTTP %d listing folder %s — " "treating Q%d %d as unavailable", folder_resp.status_code, @@ -309,7 +327,13 @@ async def check_new_quarter_available( ) return True - logger.info( + # ERROR (#2674, ревью PR #2681): файл ЕСТЬ в листинге, но HEAD отдал не zip + # либо размер ниже порога — это буквально тот сбой, из-за которого поллер уже + # врал (Bitrix-заглушка отвечала 200 с text/html вместо архива, см. "ИСТОРИЯ"). + # Ветка может сработать и легитимно — файл появился в листинге раньше, чем + # докачался, — но цена асимметрична: такт 28 дней, значит ложное срабатывание + # стоит максимум одного события в месяц, а пропуск стоит квартала молчания. + logger.error( "rosreestr_poll: Q%d %d file found (%s) but failed availability check " "(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, " "treating as unavailable", @@ -338,12 +362,14 @@ async def check_new_quarter_available( exc, ) return False - except Exception as exc: - logger.warning( - "rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable", + except Exception: + # ERROR + traceback (#2674): сюда попадает НАШ баг (сменилась разметка, упал + # парсер href'ов), а не сбой сети. Под WARNING он молча превращался в + # «квартала нет» — ровно тот сценарий, из-за которого поллер врал кварталами. + logger.exception( + "rosreestr_poll: unexpected error checking Q%d %d — treating as unavailable", quarter, year, - exc, ) return False @@ -409,6 +435,21 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]: rosreestr_dataset_url(next_year, next_quarter), _DATA_SETS_BASE_URL, ) + # #2674: это ХОРОШАЯ новость, но она требует ручного шага оператора (импорт + # много-гигабайтного ZIP), а INFO-строка живёт только в docker-логах и + # теряется на редеплое. Отсюда явный capture_message вместо logger.error: + # событие в GlitchTip будет, а error-rate и стрик-алерты не соврут «сбой». + # Шума не создаёт: такт поллера — раз в 28 дней, квартал выходит 4 раза в + # год, а повтор до самого импорта — это и есть нужное напоминание (#2670). + try: + sentry_sdk.capture_message( + f"Rosreestr: доступен новый квартал Q{next_quarter} {next_year} — " + "нужен ручной импорт (02_load_all_quarters.sh + import-rosreestr.sh)", + level="info", + ) + except Exception: + # Алертинг best-effort: падение отправки события не должно валить поллер. + logger.warning("rosreestr_poll: capture_message failed", exc_info=True) return { "available": available, diff --git a/tradein-mvp/backend/app/services/sber_index.py b/tradein-mvp/backend/app/services/sber_index.py index 36b7bc95..7a4d646c 100644 --- a/tradein-mvp/backend/app/services/sber_index.py +++ b/tradein-mvp/backend/app/services/sber_index.py @@ -464,8 +464,16 @@ async def pull_sber_indices( # path or its filter dims are stale (sber renames slugs / changes # dimension codes). Surface it loudly with the slug + filter so the # next breakage is diagnosable instead of a silent error-counter bump. + # + # #2674: "loudly" было сказано, но написано WARNING — тише, чем + # соседние 5xx/сетевые ветки, и НЕ событие в скрапере + # (LoggingIntegration event_level=ERROR). При этом 404 — самая + # ПЕРМАНЕНТНАЯ из трёх: 5xx и сетевой сбой сами пройдут, а + # переименованный slug будет 404-ить каждый месяц, пока человек не + # перезахватит dataset-path. Ровно тот сбой, из-за которого бенчмарк + # перестаёт обновляться. if exc.response.status_code == 404: - logger.warning( + logger.error( "sber_index: 404 for dashboard=%s ref_area=%s filter=%s — " "dataset-path invalid? slug renamed or filter dims stale " "(re-capture /dataset/v1/ via dashboard route-interception)", diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 114ac577..b1a43824 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -12,6 +12,7 @@ scheduling-путь (`app/scheduler_main.py` безусловно запуска Что осталось в этом модуле — НЕ scheduler-loop, а функции с живыми потребителями вне удалённой machinery: - `compute_next_run_at` — читается admin.py (операторский предпросмотр "next run"). + С #2674 это re-export kit-версии, а не вторая копия формулы. - `has_running_run` — читается admin.py (UI-индикатор "уже бежит"). - `import_rosreestr_dkp` — job-тело, вызываемое kit-handler'ом product_handlers._job_rosreestr_dkp (lazy import). @@ -25,16 +26,24 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в from __future__ import annotations import logging -import random -from datetime import UTC, datetime, time, timedelta from typing import Any +# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674). +# Обе копии одинаково умели interval_days — но такт доезжал до next_run_at только через +# kit (_claim_run/_defer_next_run_at читают default_params["interval_days"]); admin.py +# звал эту копию БЕЗ аргумента, получал default=1 и сбивал любой источник на «завтра». +# Копия удалена, а не подправлена: пока формула лежит в двух файлах, следующая правка +# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих) +# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах. +from scraper_kit.orchestration.scheduler import compute_next_run_at from sqlalchemy import text from sqlalchemy.orm import Session from app.core.shutdown import shutdown_requested from app.services import scrape_runs as runs_mod +__all__ = ["compute_next_run_at", "has_running_run"] + logger = logging.getLogger(__name__) # import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше @@ -43,54 +52,6 @@ logger = logging.getLogger(__name__) DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05 -def compute_next_run_at( - window_start_hour: int, - window_end_hour: int, - *, - now: datetime | None = None, - interval_days: int = 1, -) -> datetime: - """Pick random datetime в window [start, end) UTC, через interval_days суток после now. - - interval_days задаёт каденс источника: 1 (default) = daily (back-compat), 7 = weekly. - Берётся из schedule.default_params["interval_days"] вызывающим кодом; отсутствие ключа - → 1 → прежнее ежедневное поведение. - - Если window_end_hour <= window_start_hour → cross-midnight window - (например 22→3 → окно 22:00-23:59 ИЛИ 00:00-02:59). - """ - now = now or datetime.now(tz=UTC) - interval_days = max(1, int(interval_days)) - # Целевая дата = now + interval_days суток (interval_days=1 → завтра, как раньше). - target = (now + timedelta(days=interval_days)).date() - - if window_end_hour > window_start_hour: - # Обычное окно (например 2..5 → 02:00-04:59) - start_seconds = window_start_hour * 3600 - end_seconds = window_end_hour * 3600 - rand_seconds = random.randint(start_seconds, end_seconds - 1) - return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=rand_seconds) - else: - # Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59) - # Длина окна = (24-start) + end часов - total_seconds = ((24 - window_start_hour) + window_end_hour) * 3600 - rand_seconds = random.randint(0, total_seconds - 1) - # Если rand попадает в первую часть (start..24) - first_half = (24 - window_start_hour) * 3600 - if rand_seconds < first_half: - # interval_days=1: текущая дата (если окно ещё не наступило сегодня) или next day. - # interval_days>1: всегда целевая дата (стаггер на N суток вперёд). - today_ok = interval_days == 1 and now.hour < window_start_hour - base_date = now.date() if today_ok else target - return datetime.combine(base_date, time(0, 0), tzinfo=UTC) + timedelta( - seconds=window_start_hour * 3600 + rand_seconds - ) - else: - # Во второй части (0..end), целевого дня - offset = rand_seconds - first_half - return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=offset) - - def has_running_run(db: Session, source: str) -> bool: """Есть ли активный run для source (status='running').""" row = db.execute( @@ -115,7 +76,16 @@ async def _execute_cian_backfill( """Orchestrate Cian history backfill with heartbeat + checkpoint. Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat) - before and after the batch call for zombie-detection visibility. + НА КАЖДОЙ сущности батча, а не только до и после него (#2725). Раньше сигнал + живости слался ровно один раз — до батча, — а `reap_zombies` меряет именно + heartbeat_at с порогом 6 ч, и добивал живые прогоны строго на 6-м часу: 6 прод- + прогонов этого источника помечены 'zombie' со сдвигом heartbeat 16-32 мс, при том + что у пятерых внутри окна писались строки offer_price_history (у прогона 304 — до + 5.4 ч после старта), а штатная длительность источника доходит до 5.06 ч (346). + Цена ошибки не косметическая: mark_done апдейтит WHERE status='running', так что + после ложной пометки собственный финал прогона становится no-op (отсюда нулевые + counters у всех шести), а has_running_run перестаёт видеть прогон и следующий тик + может запустить второй такой же батч поверх работающего. Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS NULL via LEFT JOIN — so re-running after a partial completion naturally skips @@ -124,9 +94,33 @@ async def _execute_cian_backfill( Params (from default_params jsonb): batch_size: int — rows per run (listings + houses counted separately). """ - from app.tasks.cian_history_backfill import backfill_cian_history + from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history batch_size = int(params.get("batch_size", 100)) + + def _counters(result: CianBackfillResult) -> dict[str, int]: + return { + "listings_processed": result.listings_processed, + "listings_succeeded": result.listings_succeeded, + "listings_failed": result.listings_failed_fetch + result.listings_failed_save, + "houses_processed": result.houses_processed, + "houses_succeeded": result.houses_succeeded, + "houses_failed": result.houses_failed_fetch + result.houses_failed_save, + } + + def _heartbeat(progress: CianBackfillResult) -> None: + """Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен + ронять уже идущую работу — прогон в худшем случае вернётся к прежнему + поведению (пометка 'zombie' на 6-м часу).""" + try: + runs_mod.update_heartbeat(db, run_id, _counters(progress)) + except Exception: + logger.warning( + "scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)", + run_id, + exc_info=True, + ) + counters: dict[str, int] = { "listings_processed": 0, "listings_succeeded": 0, @@ -145,17 +139,10 @@ async def _execute_cian_backfill( do_listings=True, do_houses=True, do_valuations=False, + on_progress=_heartbeat, ) - counters = { - "listings_processed": result.listings_processed, - "listings_succeeded": result.listings_succeeded, - "listings_failed": result.listings_failed_fetch + result.listings_failed_save, - "houses_processed": result.houses_processed, - "houses_succeeded": result.houses_succeeded, - "houses_failed": result.houses_failed_fetch + result.houses_failed_save, - "duration_sec": int(result.duration_sec), - } + counters = {**_counters(result), "duration_sec": int(result.duration_sec)} runs_mod.mark_done(db, run_id, counters) logger.info( "scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 39c91d5a..0ecf5ee2 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -2,13 +2,39 @@ Таблица scrape_runs создана в 015_scrape_runs.sql. Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled. + +ВРЕМЯ ПИШЕТСЯ clock_timestamp(), А НЕ now() (#2702). `now()` в PostgreSQL — +синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции и не двигается, +сколько бы та ни жила. Финализаторы (mark_done/mark_failed/mark_banned) выполняются +ТОЙ ЖЕ сессией, что и работа задачи, — и если рабочая транзакция всё это время +оставалась открытой (задача ничего не коммитила: нечего было сохранять, батч читающий, +сохранение шло чужой сессией), их UPDATE попадал ВНУТРЬ неё, и `finished_at` получал +время НАЧАЛА работы, а не её конца. + +Замер на проде 2026-08-06 (487 прогонов, у которых есть и finished_at, и счётчик +counters.duration_sec): у 153 заявленная длительность превышала собственное окно +finished_at − started_at более чем в 1.5 раза, у 133 окно было меньше секунды при +работе дольше 10 с. 126 из этих 133 окон лежат в диапазоне 9-64 мс — это не разброс, +а подпись механизма: столько проходит от коммита claim'а до первого запроса рабочей +транзакции. Крайний случай — прогон 346 (cian_history_backfill): 18230 с работы, +окно 32 мс. + +Дефект был не сплошной ровно потому, что зависел от того, коммитила ли задача перед +финалом: cadastral_geo_match / house_imv_backfill / avito_detail_backfill коммитят +поштучно, у них окно совпадало с работой; yandex_address_backfill (45 из 50 прогонов), +newbuilding_enrich, cian_history_backfill — нет. + +Побочно это чинит и `heartbeat_at`: он писался тем же `now()` и по той же причине +отставал от реальности на возраст открытой транзакции, а на нём стоит поиск зависших +прогонов (reap_zombies, порог 6 ч). """ from __future__ import annotations import json import logging -from collections.abc import Callable +from collections.abc import Callable, Collection, Mapping +from functools import cache from typing import Any import sentry_sdk @@ -29,6 +55,232 @@ CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3 # невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned). CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3 +# #2670: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик прерывается +# не только в принципе, но и на практике. Оба сторожа ниже слали алерт ровно на N-й +# подряд неудаче и дальше молчали навсегда — а у постоянно сломанного источника +# «дальше» длится месяцами. Прод 2026-08-06: у avito_full_load 31 неудача подряд, +# последний успешный прогон 03.07 (34 дня без сбора), алерт был ровно один — на +# третьей; у avito_full_load_exhaustive 5 подряд. Тишина при этом неотличима от +# «всё хорошо» — ровно та ловушка, из-за которой #2574 месяц выглядела как норма. +# +# Вместо «ровно N» — разреженная лестница напоминаний: N, 2N, 4N, 8N…, а дальше не +# реже, чем раз в STREAK_ALERT_MAX_PERIOD×N прогонов. Лестница по ПРОГОНАМ, а не +# «раз в сутки», потому что источники идут разным тактом: domclick_city_sweep — раз +# в день, proxy_healthcheck — раз в полчаса; календарное разрежение для одного из +# них всегда будет либо спамом, либо молчанием. +STREAK_ALERT_MAX_PERIOD = 16 + +# Потолок сканирования истории источника при подсчёте стрика. Достигнутый потолок +# сам по себе повод для алерта (стрик заведомо огромен) — так «замолчать навсегда» +# невозможно по построению, а не по счастливому совпадению чисел. +STREAK_SCAN_LIMIT = 500 + + +def _streak_alert_due(streak: int, threshold: int) -> bool: + """Достиг ли стрик очередной вехи напоминания (#2670). + + True на threshold, 2×, 4×, 8×… и дальше на каждом кратном + STREAK_ALERT_MAX_PERIOD×threshold. Первый алерт приходит там же, где и раньше — + на N-й подряд неудаче; меняется только то, что он не последний. + """ + if streak < threshold or streak % threshold: + return False + mult = streak // threshold + if mult % STREAK_ALERT_MAX_PERIOD == 0: + return True + return mult & (mult - 1) == 0 + + +def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int: + """Длина серии подряд идущих «плохих» строк с начала списка (свежие — первыми).""" + streak = 0 + for row in rows: + if not is_bad(row): + break + streak += 1 + return streak + + +# #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218) +# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый +# статус» и «явное поле причины»: +# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed +# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив +# статус, пришлось бы дублировать её в каждом потребителе. +# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых +# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы, +# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results), +# Literal-фильтр admin API и хардкод-список статусов во фронте. Это ровно тот +# класс оборванной проводки, из-за которого задача и появилась. +# 3. Прогон в обоих случаях требует одного и того же обращения (оборвать, сохранить +# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние. +BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее +BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее +# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform': +# на проде оба прогона, помеченных после миграции 218, получили 'platform' по +# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не +# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а +# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения. +BAN_KIND_UNKNOWN = "unknown" + + +def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: + """Первое присутствующее из ``keys`` как int; None — ни одного ключа нет.""" + for key in keys: + val = counters.get(key) + if val is not None: + try: + return int(val) + except (TypeError, ValueError): + return None + return None + + +# #2703: ключи, которыми задача сообщает СВОЙ бизнес-результат. Список намеренно +# короткий и состоит из синонимов ОДНОЙ величины — «сколько объявлений отдала выдача»: +# total_seen — если задача посчитала сама; +# lots_fetched — все city/newbuilding-sweep'ы (21 источник, 455 прогонов на проде); +# unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше +# сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов +# реально дали ноль. +# Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 +# прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь +# (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched +# …), а у refresh_search_matview counters пусты буквально ({} во всех 55 строках) и у +# трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ +# (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их +# словарь, а честно признаёт, что мерить нечем — см. _run_result_count. +_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched") + + +def _run_result_count(counters: Mapping[str, Any] | None) -> int | None: + """Бизнес-результат прогона; **None = прогон его не сообщил** (≠ ноль). + + Ровно это различие и было потеряно: сторож читал колонку ``total_seen``, у + которой DEFAULT 0, поэтому «не измерено» и «измерено, ноль» выглядели одинаково. + """ + return _pick_int(counters or {}, *_RESULT_COUNTER_KEYS) + + +@cache +def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> None: + """Один раз на процесс: у источника нет ключа, по которому сторож судит (#2703). + + Не алерт — алертить не о чем, судить не о чем тоже. Это делает слепую зону + ВИДИМОЙ: раньше её признаком был вечно молчащий сторож, выглядящий настроенным. + """ + logger.warning( + "zero-result watchdog неприменим к source=%s: counters не содержат ни одного " + "результатного ключа %s (есть: %s) — прогоны этого источника больше не считаются " + "нулевыми по умолчанию (#2703)", + source, + _RESULT_COUNTER_KEYS, + ", ".join(keys) or "<пусто>", + ) + + +def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: + """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625). + + Возвращает текст причины (для error) либо None, если прогон таким не является. + + Третий исход, у которого не было терминального статуса. Развёртка различает: + 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу → + `mark_banned` в самих sweep'ах (#2642, cian/yandex); + 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений → + `done` с нулём, это здоровый результат (в Серове реально 10 объявлений); + 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как + что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает + НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки), + и статуса ему никто не выдал — прогон уходил в `done`. + + Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров: + `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что + отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ + доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус + 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается + по умолчанию). + + Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала — + `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор). + + Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов + (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с, + таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5; + yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса). + НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной + пустоты (errors_count=0) — они остаются 'done'. + """ + anchors = _pick_int(counters, "anchors_total") + errors = _pick_int(counters, "errors_count") + if not anchors or anchors <= 0 or errors is None or errors < anchors: + return None + if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает + return None + return ( + f"sweep-honest-status: отказом кончились все {anchors} якорей прогона " + f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ " + f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или " + f"блокировкой площадки — статус 'failed' без диагноза (#2625)" + ) + + +# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило. +# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд +# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу +# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90 +# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не +# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд. +_PHASE_MIN_ATTEMPTS = 3 + + +def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: + """Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None. + + Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит + случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на + сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ. + + Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50, + detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала + HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового + прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ + подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`). + + Признак — собственная бухгалтерия фазы: `_failed == _attempted` при + `attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ + `X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это + ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя + настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv. + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'. + + Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по + боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed' + 42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не + знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 — + это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов + отказали обе фазы (detail и houses) сразу, и они посчитались дважды. + """ + for key in sorted(counters): + if not key.endswith("_attempted"): + continue + phase = key[: -len("_attempted")] + attempted = _pick_int(counters, key) + failed = _pick_int(counters, f"{phase}_failed") + if attempted is None or failed is None: + continue + if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted: + return ( + f"phase-honest-status: фаза '{phase}' отказала полностью — " + f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы " + f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. " + f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус " + f"'failed' без диагноза (#2700)" + ) + return None + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -40,41 +292,37 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926). Приоритет ключей: - - total_seen ← 'total_seen' (если уже есть в counters) иначе 'lots_fetched' - - new_count ← 'new_count' (если уже есть) иначе 'lots_inserted' + - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched) + - new_count ← 'new_count' (если уже есть) иначе 'lots_inserted' Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). """ - - def _pick(*keys: str) -> int | None: - for key in keys: - val = counters.get(key) - if val is not None: - try: - return int(val) - except (TypeError, ValueError): - return None - return None - - return _pick("total_seen", "lots_fetched"), _pick("new_count", "lots_inserted") + return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted") def _alert_if_consecutive_failures(db: Session, source: str) -> None: - """Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD - завершённых запусков для данного source имеют статус 'failed' или 'banned'. + """Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд + (статусы 'failed'/'banned') у данного source. - Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос - возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является - failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога. + Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см. + _streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не + повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде + это дало avito_full_load — 31 неудача подряд, 34 дня без единого успешного + прогона, один алерт за всё время. + + Стрик прерывается любым завершением, кроме failed/banned, — по данным прода это + достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47 + завершённых прогонах), поэтому лестница не вырождается в постоянный алерт. Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный Sentry НЕ должен нарушать вызывающий mark_* путь. """ + if sentry_sdk is None: + return n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD try: - # Берём последние N+1 завершённых (non-running) запусков по source. - # Сортируем по finished_at DESC чтобы самые свежие шли первыми. + # Завершённые (non-running) прогоны источника, самые свежие первыми. rows = db.execute( text( """ @@ -85,31 +333,21 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None: LIMIT :limit """ ), - {"source": source, "limit": n + 1}, + {"source": source, "limit": STREAK_SCAN_LIMIT}, ).fetchall() - if len(rows) < n: - # Ещё не набралось N завершённых запусков вообще — алерт не нужен. + streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned")) + capped = streak >= STREAK_SCAN_LIMIT + if not capped and not _streak_alert_due(streak, n): return - # Первые N должны быть все failed/banned. - first_n = rows[:n] - if not all(r.status in ("failed", "banned") for r in first_n): - return - - # (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже - # должны были отправить алерт раньше и не стоит дублировать. - if len(rows) > n and rows[n].status in ("failed", "banned"): - return - - # Стрик ровно достиг порога — отправляем алерт. sentry_sdk.capture_message( - f"Scraper source '{source}' has {n} consecutive failed/banned runs — " + f"Scraper source '{source}' has {streak} consecutive failed/banned runs — " "manual intervention may be required (expired cookies / ban / broken parser).", level="error", ) logger.error( - "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n + "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, streak ) except Exception: pass # sentry_sdk not initialised in dev, or query failed — best-effort only @@ -117,15 +355,23 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None: def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: """Отправить Sentry alert если последние CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD - завершённых 'done' запусков для source имеют total_seen=0 (#2625). + завершённых 'done' запусков для source дали ИЗМЕРЕННЫЙ нулевой результат (#2625). Отличается от _alert_if_consecutive_failures: статус здесь формально 'done' (errors_count=0) — деградация невидима существующему failed/banned алерту. Причина обычно капча/пустая выдача источника, у которого нет (или не сработал) детект блокировки (см. providers/cian/serp.py, providers/yandex/serp.py). - Anti-spam: тот же N-й-стрик паттерн, что у _alert_if_consecutive_failures — - алерт срабатывает ровно когда стрик достигает порога, не на каждом запуске сверх. + Anti-spam: та же разреженная лестница вех, что у _alert_if_consecutive_failures + (#2670) — N, 2N, 4N…, а не «ровно N и дальше тишина». + + #2703: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик может + прерваться. Сторож читал колонку total_seen (DEFAULT 0), которой у 28 из 53 + источников не заполняет ничто — значит у них он читал 0 ВСЕГДА, в том числе у + полностью успешного прогона, стрик не прерывался никогда, и после первого + события сторож замолкал навсегда, продолжая выглядеть настроенным. Теперь + признак берётся из counters, а «не измерено» (None) стрик ПРЕРЫВАЕТ — ложный + вечный стрик стал невозможен по построению, а слепая зона логируется явно. Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный Sentry НЕ должен нарушать вызывающий mark_done путь. @@ -134,35 +380,47 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: try: # Те же non-running статусы, что у _alert_if_consecutive_failures — стрик # 'done'-с-нулём прерывается ЛЮБЫМ другим завершением (failed/banned/done- - # с-результатом/cancelled), не только успешным сбором. + # с-результатом/cancelled/прогон без результатной метрики), не только успешным + # сбором. counters, а НЕ колонка total_seen: у колонки DEFAULT 0, по ней + # «не измерено» неотличимо от «ноль» (#2703). rows = db.execute( text( """ - SELECT status, total_seen FROM scrape_runs + SELECT status, counters FROM scrape_runs WHERE source = :source AND status IN ('failed', 'banned', 'done', 'cancelled') ORDER BY finished_at DESC NULLS LAST LIMIT :limit """ ), - {"source": source, "limit": n + 1}, + {"source": source, "limit": STREAK_SCAN_LIMIT}, ).fetchall() - if len(rows) < n: + if not rows: return def _is_zero_done(r: Any) -> bool: - return r.status == "done" and (r.total_seen or 0) == 0 + """Только ИЗМЕРЕННЫЙ ноль. Прогон без результатной метрики стрик ПРЕРЫВАЕТ. - first_n = rows[:n] - if not all(_is_zero_done(r) for r in first_n): + Так недостижимое условие прерывания невозможно по построению: источник, + чей словарь счётчиков сторожу неизвестен, не копит ложный стрик и не + запирает анти-спам «один раз на стрик» в «один раз навсегда». + """ + return r.status == "done" and _run_result_count(r.counters) == 0 + + if _run_result_count(rows[0].counters) is None: + # Свежайший завершённый прогон не сообщил результата — судить нечем. + # Логируем (один раз на источник за процесс) вместо молчаливого нуля. + _warn_source_has_no_result_metric(source, tuple(sorted(rows[0].counters or {}))) return - if len(rows) > n and _is_zero_done(rows[n]): + streak = _leading_streak(rows, _is_zero_done) + capped = streak >= STREAK_SCAN_LIMIT + if not capped and not _streak_alert_due(streak, n): return sentry_sdk.capture_message( - f"Scraper source '{source}' has {n} consecutive 'done' runs with zero " + f"Scraper source '{source}' has {streak} consecutive 'done' runs with zero " "lots fetched — captcha/layout-change likely undetected " "(manual check recommended).", level="error", @@ -170,7 +428,7 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: logger.error( "sentry alert sent: source=%s has %d consecutive zero-result 'done' runs", source, - n, + streak, ) except Exception: pass # sentry_sdk not initialised in dev, or query failed — best-effort only @@ -199,16 +457,23 @@ def _alert_on_run_id( def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: - """INSERT scrape_runs(source, status='running', params, started_at=NOW()). + """INSERT scrape_runs(source, status='running', params, started_at=clock_timestamp()). - run_type DEFAULT 'city_sweep' (из 051 миграции). + started_at пишется СВОЕЙ транзакцией (db.commit() ниже) — откат рабочей + транзакции задачи его уже не достаёт (#2702). + + Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …); + отдельной колонки run_type больше нет — она 3244 прогона подряд молчала + дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674). Returns run_id (bigint). """ row = db.execute( text( """ INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at) - VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW()) + VALUES ( + :source, 'running', CAST(:params AS jsonb), clock_timestamp(), clock_timestamp() + ) RETURNING id """ ), @@ -220,7 +485,7 @@ def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None: - """UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки. + """UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки. total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926). @@ -231,7 +496,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None text( """ UPDATE scrape_runs - SET heartbeat_at = NOW(), + SET heartbeat_at = clock_timestamp(), counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -248,6 +513,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None db.commit() +# Источники, чей джоб РЕАЛЬНО опрашивает status='cancelled' в своём цикле. +# Всё остальное отменить нельзя: строка стала бы 'cancelled', а задача продолжила бы +# работать — это, во-первых, ещё один врущий статус, во-вторых (хуже) обход guard'а +# has_running_run: он перестанет видеть прогон как running и пустит второй свип на том +# же прокси-IP → бан (инцидент 2026-05-31, runs #26+#27). +# Состав проверен по call-site'ам runs.is_cancelled: kit pipeline (city-sweep'ы всех +# площадок и городов, full-load'ы, avito_newbuilding_sweep) + rosreestr_dkp_import +# (scheduler.py). yandex_newbuilding_sweep отмену НЕ опрашивает — поэтому правило не +# «любой *_sweep». Актуально с #2674: до починки фильтра таблица прогонов была пуста +# на всех вкладках, кнопка отмены не рендерилась ни разу и дыра не проявлялась. +_CANCEL_HONORING_EXACT = frozenset({"avito_newbuilding_sweep", "rosreestr_dkp_import"}) +_CANCEL_HONORING_SUBSTRINGS = ("city_sweep", "full_load") + + +def honors_cancel(source: str) -> bool: + """True, если джоб этого source опрашивает отмену и реально остановится.""" + return source in _CANCEL_HONORING_EXACT or any( + key in source for key in _CANCEL_HONORING_SUBSTRINGS + ) + + def is_cancelled(db: Session, run_id: int) -> bool: """Проверить status='cancelled' (cooperative cancel в long-running pipeline).""" row = db.execute( @@ -258,17 +544,38 @@ def is_cancelled(db: Session, run_id: int) -> bool: def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: - """Финализация run: status='done', finished_at=NOW(), counters + total_seen/new_count. + """Финализация run: status='done', finished_at + counters + total_seen/new_count. total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки — иначе admin/observability показывает 0 (audit #1926). + + #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился + каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а + не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: + страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, + из-за которого задача и появилась. + + #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка + целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано + ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет». """ + did_nothing = _sweep_run_did_nothing(counters) + if did_nothing is not None: + logger.error("%s run_id=%d", did_nothing, run_id) + mark_failed(db, run_id, did_nothing, counters) + return + phase_dead = _phase_totally_failed(counters) + if phase_dead is not None: + logger.error("%s run_id=%d", phase_dead, run_id) + mark_failed(db, run_id, phase_dead, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( """ UPDATE scrape_runs - SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'done', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -307,7 +614,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) text( """ UPDATE scrape_runs - SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'failed', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), error = :error, counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -330,11 +638,30 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) _alert_on_run_id(db, run_id) -def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None: - """Финализация run: status='banned' (IP заблокирован Avito — 403/captcha). +def mark_banned( + db: Session, + run_id: int, + error: str, + counters: dict[str, int], + *, + ban_kind: str = BAN_KIND_UNKNOWN, +) -> None: + """Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764). Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'. - Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа. + Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим + багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его + теряет). Cooldown 2-4 часа. + + `ban_kind` разводит два исхода, которые раньше схлопывались в один статус: + - BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha); + - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси); + - BAN_KIND_UNKNOWN (дефолт) — причина не установлена. + Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора + текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому + разводить нечего, ничего и не знает — а не «знает, что виновата площадка». + + Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом. Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE, он мог оставить сессию в error state — rollback сбрасывает состояние. @@ -348,8 +675,10 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) text( """ UPDATE scrape_runs - SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'banned', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), error = :error, counters = CAST(:counters AS jsonb), + ban_kind = :ban_kind, total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) WHERE id = :run_id AND status = 'running' @@ -360,6 +689,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) "run_id": run_id, "error": error[:1000], "counters": json.dumps(counters), + "ban_kind": ban_kind, "total_seen": total_seen, "new_count": new_count, }, @@ -371,13 +701,117 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) _alert_on_run_id(db, run_id) +def mark_backfill_finished( + db: Session, + run_id: int, + counters: dict[str, int], + *, + source: str, + aborted_by_blocks: bool = False, + fail_hint: str | None = None, + ban_kinds: Collection[str] = (), +) -> None: + """Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'. + + Все три detail-backfill'а (avito/yandex/domclick) финализировались ОДНИМ + mark_done: прогон, который сделал N попыток и не обогатил НИ ОДНОГО объявления, + отчитывался успехом. На проде (2026-08-06) это 78 прогонов из 158 — + avito 23/76 (в т.ч. 5 прогонов по 1500-1600 попыток с нулём обогащений), + yandex 31/52 (все attempted=5 failed=5), domclick 24/30 (494 попытки → 0). + + Существующие алерты этот класс не ловили: _alert_if_consecutive_failures + считает только failed/banned, а _alert_if_consecutive_zero_results смотрит + total_seen, которого в counters backfill'ов нет вовсе (всегда 0 → стрик не + прерывается никогда → анти-спам молчит после первого раза). + + Правила (порядок важен), по образцу #2657 для domclick_city_sweep: + - попыток не было (attempted=0) → 'done', честная пустота: кандидатов нет; + - есть блоки источника И (прогон оборван брейкером ИЛИ ноль результата) + → 'banned': external constraint, не наш баг (и триггер ротации IP #2611); + - ноль результата без блоков → 'failed': это наша поломка (парсер/сеть/БД); + - иначе (обогатили хоть что-то) → 'done', в т.ч. частичный прогон. + + `gone` (404 у avito) считается результатом наравне с `enriched`: прогон, + который подтвердил снятие объявлений, работу сделал. + + `fail_hint` — самая частая причина отказа этого прогона (задача считает её сама, + см. avito_detail_backfill._failure_signature). Дописывается в текст статуса, + потому что «blocked=5, обогащено 0» не отвечает на единственный вопрос, ради + которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи + контейнера на этот вопрос отвечать не могут — они исчезают при пересоздании + контейнера, то есть на первом же деплое после ночного прогона. + + `ban_kinds` — диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача + поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами, + в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и + пишется; разошлись (или их типы ничего не доказывают) → 'unknown'. Смешанный + прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что + попалась последней — какая из них оборвала прогон, мы не знаем (#2764). + """ + attempted = int(counters.get("attempted") or 0) + enriched = int(counters.get("enriched") or 0) + blocked = int(counters.get("blocked") or 0) + produced = enriched + int(counters.get("gone") or 0) + hint = f"; причина: {fail_hint}" if fail_hint else "" + + if attempted == 0: + mark_done(db, run_id, counters) + return + + if blocked and (aborted_by_blocks or produced == 0): + reason = ( + f"backfill-honest-status: {source} остановлен блоками источника — " + f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)" + ) + logger.error("%s run_id=%d", reason, run_id) + kinds = set(ban_kinds) + mark_banned( + db, + run_id, + reason, + counters, + ban_kind=kinds.pop() if len(kinds) == 1 else BAN_KIND_UNKNOWN, + ) + return + + if produced == 0: + reason = ( + f"backfill-honest-status: {source} без результата — 0 обогащено из " + f"{attempted} попыток (failed={counters.get('failed', 0)}, " + f"blocked={blocked}){hint} (#2674)" + ) + logger.error("%s run_id=%d", reason, run_id) + mark_failed(db, run_id, reason, counters) + return + + mark_done(db, run_id, counters) + + def mark_cancelled(db: Session, run_id: int) -> bool: - """Set status='cancelled' если currently 'running'. Returns True если cancelled.""" + """Set status='cancelled' если currently 'running'. Returns True если cancelled. + + Отказ (False) для source'ов, чей джоб отмену не опрашивает — см. honors_cancel: + там 'cancelled' был бы враньём в статусе и снял бы has_running_run-guard. + Ручки отмены source не проверяют (любая из пяти принимает любой run_id), поэтому + гейт стоит здесь — на общем узле всех пяти. + """ + row = db.execute( + text("SELECT source FROM scrape_runs WHERE id = :run_id"), + {"run_id": run_id}, + ).fetchone() + if row is not None and not honors_cancel(str(row.source)): + logger.warning( + "mark_cancelled отказ: run_id=%d source=%s не опрашивает отмену — " + "задача продолжила бы работать под статусом 'cancelled'", + run_id, + row.source, + ) + return False result = db.execute( text( """ UPDATE scrape_runs - SET status = 'cancelled', finished_at = NOW() + SET status = 'cancelled', finished_at = clock_timestamp() WHERE id = :run_id AND status = 'running' RETURNING id """ @@ -445,8 +879,8 @@ def list_all( db.execute( text( f""" - SELECT id AS run_id, source, run_type, status, params, counters, - total_seen, new_count, started_at, finished_at, + SELECT id AS run_id, source, status, params, counters, + ban_kind, total_seen, new_count, started_at, finished_at, heartbeat_at, error AS error_text FROM scrape_runs WHERE {where_sql} @@ -460,3 +894,22 @@ def list_all( .all() ) return total, [dict(r) for r in rows] + + +def distinct_sources(db: Session) -> list[str]: + """Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту). + + #2674: фильтр источников в админке был захардкожен тремя площадками + (avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким + точным значением — все три пункта фильтра давали пустую выдачу, а 76% + прогонов (включая всю площадку Домклик) отфильтровать было нечем. + Список обязан приходить из данных: новый source появляется в фильтре сам, + без правки кода. + + Игнорирует фильтры /scrape/runs — иначе выбор источника вырезал бы из + выпадающего списка все остальные. + """ + rows = db.execute( + text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source") + ).fetchall() + return [str(r.source) for r in rows] diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 15d228f0..01bf055a 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -66,8 +66,8 @@ class RealMatcherAdapter: *, year_built: int | None = None, building_cadastral_number: str | None = None, - cadastral_number: str | None = None, source_url: str | None = None, + city: str | None = None, ) -> tuple[int | None, float, str]: # house_id is None when the matcher refuses a numberless address without a # cadastral number (method 'no_house_number', P1). Callers must tolerate None. @@ -80,8 +80,8 @@ class RealMatcherAdapter: lon, year_built=year_built, building_cadastral_number=building_cadastral_number, - cadastral_number=cadastral_number, source_url=source_url, + city=city, ) def upsert_listing_source( diff --git a/tradein-mvp/backend/app/services/search_query.py b/tradein-mvp/backend/app/services/search_query.py index b7d06ed7..55890449 100644 --- a/tradein-mvp/backend/app/services/search_query.py +++ b/tradein-mvp/backend/app/services/search_query.py @@ -95,8 +95,12 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]: where.append("total_floors <= CAST(:fl_total_max AS integer)") args["fl_total_max"] = params.floors_total_max - if params.has_kadastr: - where.append("cadastral_number IS NOT NULL") + # Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ) + # пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель, + # парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат + # `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал + # качество данных, которого нет. Колонка и её писатель оставлены: если площадка + # начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр. segment_clause = _SEGMENT_SQL[params.segment] if segment_clause is not None: diff --git a/tradein-mvp/backend/app/services/yandex_address_backfill.py b/tradein-mvp/backend/app/services/yandex_address_backfill.py index 1b5984f3..b4a660c5 100644 --- a/tradein-mvp/backend/app/services/yandex_address_backfill.py +++ b/tradein-mvp/backend/app/services/yandex_address_backfill.py @@ -111,7 +111,7 @@ async def backfill_yandex_addresses( Returns: YandexAddressBackfillResult with checked/saved/skipped/errors counters. """ - from app.core.config import settings + from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url result = YandexAddressBackfillResult() t0 = time.time() @@ -130,7 +130,23 @@ async def backfill_yandex_addresses( request_delay_sec, ) - _proxy_url = settings.scraper_proxy_url + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если пул + # пуст (легитимный dev/staging-сценарий). + try: + _proxy_url = resolve_proxy_url(db, "yandex") + except ProxyPoolExhaustedError as exc: + # Fail-closed (#2616, #2825): пул не пуст, но все узлы забанены для yandex/ + # нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module + # docstring). Явный пропуск run'а вместо слепого прохода через egress, который + # мог быть источником текущего инцидента. + logger.error( + "yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run " + "пропущен, ни один листинг не обработан", + exc, + ) + result.duration_sec = time.time() - t0 + return result _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None async with AsyncSession( diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 1c38ee2d..a45af2d3 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -10,8 +10,22 @@ Legacy listings (older than 2h or outside radius) are never enriched. Solution: single snapshot SELECT at start (guarantees termination), same proxy session path as the detail-phase of `run_avito_city_sweep` (scraper_kit.orchestration.pipeline). Block handling mirrors that phase: -rotate IP on every block, abort after max_consecutive_blocks (mark_done not -mark_failed -- block is temporary, retry next night via NULL detail_enriched_at). +rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного +блоками прогона — 'banned' (#2674, runs.mark_backfill_finished): работу он не +доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at. + +Отказы, не являющиеся блоками, до 2026-08-06 брейкера не имели вовсе: прогоны +3-5 августа делали ~1600 попыток, получали 1600 отказов, ноль обогащений и +выедали весь бюджет (9000 с) вместе с 1600 запросами через единственный прокси. +Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина +отказа пишется в текст статуса прогона (_failure_signature) — иначе она живёт +только в логах контейнера, а те исчезают на первом же деплое. + +ДИАГНОЗ бана (#2764): каждый пойманный блок классифицируется по ТИПУ исключения +(ban_kind_of_exception) и уходит в scrape_runs.ban_kind. До этой правки прогон +3306 (blocked=5, 0 обогащено) получил 'platform' по УМОЛЧАНИЮ — финализатор +диагноз не передавал, а browser-режим fetch_detail всё равно превращал отказ +сайдкара в AvitoBlockedError, так что передавать было бы нечего. """ from __future__ import annotations @@ -19,7 +33,9 @@ from __future__ import annotations import asyncio import logging import random +import re import time +from collections import Counter from dataclasses import dataclass, field from urllib.parse import urlparse @@ -30,7 +46,7 @@ from scraper_kit.avito_exceptions import ( AvitoRateLimitedError, ) from scraper_kit.browser_fetcher import BrowserFetcher -from scraper_kit.orchestration.pipeline import CITY_LOCATIONS +from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_exception # #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше # _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline. @@ -47,12 +63,14 @@ from scraper_kit.providers.avito.detail import ( save_detail_enrichment, ) from scraper_kit.providers.avito.serp import AvitoScraper +from scraper_kit.snapshot_writer import upsert_listing_snapshot from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings from app.core.shutdown import shutdown_requested from app.services import scrape_runs as runs_mod +from app.services.proxy_egress import resolve_proxy_url from app.services.scraper_adapters import RealScraperConfig # #2397 Part D1 (#2330 закрыт): _AVITO_WARM_SEARCH_URL/build_warmed_session больше @@ -97,6 +115,38 @@ _OBLAST_AVITO_URL_PATTERNS = tuple( ) +# Причина отказа карточки без её URL: 1576 отказов одного прогона должны схлопнуться +# в ОДНУ строку, иначе перепись бесполезна. +_URL_IN_MESSAGE_RE = re.compile(r"https?://\S+") + + +def _failure_signature(exc: BaseException) -> str: + """Подпись причины отказа: тип исключения + текст без URL. + + Зачем (замер 2026-08-06): у прогонов 3 и 4 августа counters говорили + `attempted=1576, failed=1576, blocked=0` — и ничего больше. Кто отказал, + площадка или наш тракт, было видно ТОЛЬКО в логах контейнера, а тот + пересоздаётся на каждом деплое и уносит их с собой; в GlitchTip попадают + события уровня ERROR, а поштучные отказы — WARNING. Разница между этими + двумя диагнозами — разные владельцы задачи (#2686, #2698), поэтому она + обязана переживать перезапуск контейнера, то есть лежать в самом прогоне. + + Тип исключения — первый разряд диагноза (AvitoBlockedError = площадка + показала 403/firewall; сетевой класс curl_cffi = наш прокси-тракт; + ValueError = ответ пришёл, но не разобран), текст — второй. + """ + message = _URL_IN_MESSAGE_RE.sub("", str(exc)).strip() + return f"{type(exc).__name__}: {message}"[:160] if message else type(exc).__name__ + + +def _top_failure(census: Counter[str]) -> str | None: + """Самая частая причина отказа с её долей; None — отказов не было.""" + if not census: + return None + reason, hits = census.most_common(1)[0] + return f"{reason} ({hits} из {sum(census.values())})" + + @dataclass class AvitoDetailBackfillResult: """Counters for one backfill run.""" @@ -137,15 +187,25 @@ async def run_avito_detail_backfill( budget_sec: float -- wall-clock budget per run, default 3600s. request_delay_sec: float -- delay between listings, default 6.0s. max_consecutive_blocks: int -- abort threshold, default 5. + max_consecutive_failures: int -- порог обрыва по отказам-не-блокам, + default 25 (см. комментарий у чтения параметра ниже). Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> - mark_done (incl. partial/block-abort) / mark_failed (exception only). + mark_backfill_finished (done / banned при блоках / failed при нуле, #2674); + mark_failed напрямую — только при исключении. """ batch_size = int(params.get("batch_size", 800)) oblast_batch_size = int(params.get("oblast_batch_size", 100)) budget_sec = float(params.get("budget_sec", 3600)) request_delay_sec = float(params.get("request_delay_sec", 6.0)) max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5)) + # Брейкер на отказы-НЕ-блоки. Блоки свой брейкер имели с самого начала, отказы — + # нет, и это стоило трёх ночей подряд: 3-5 августа прогон делал ~1600 попыток, + # получал 1600 отказов, ноль обогащений и выедал весь бюджет 9000 с (плюс 1600 + # запросов через единственный прокси, #2638). Порог заметно выше блочного: пачка + # мёртвых карточек (404 → ValueError в curl-режиме) не должна обрывать здоровый + # прогон, а 25 отказов подряд без единого успеха — уже не невезение. + max_consecutive_failures = int(params.get("max_consecutive_failures", 25)) warm_batch = int(params.get("warm_batch", 500)) research_every = int(params.get("research_every", 50)) block_cooldown_sec = float(params.get("block_cooldown_sec", 30.0)) @@ -209,12 +269,19 @@ async def run_avito_detail_backfill( elif not use_curl: # curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False): # строим shared сессию через auv, как делает run_avito_city_sweep (kit). + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только + # если пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все + # забанены/нездоровы для avito -- resolve_proxy_url бросает + # ProxyPoolExhaustedError (fail-closed, #2616): НАРОЧНО не ловим здесь -- + # штатный except Exception ниже (mark_failed + logger.exception + raise) + # уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен. own_session = True session = AsyncSession( impersonate="chrome120", timeout=25, headers=DOCUMENT_HEADERS, - proxies=http_proxies(settings.scraper_proxy_url), + proxies=http_proxies(resolve_proxy_url(db, "avito")), ) scraper._cffi = session @@ -241,7 +308,7 @@ async def run_avito_detail_backfill( text( """ WITH ekb AS ( - SELECT id, source_url, 'ekb' AS city_scope + SELECT id, source_url, price_rub, 'ekb' AS city_scope FROM listings WHERE source = 'avito' AND detail_enriched_at IS NULL @@ -254,7 +321,7 @@ async def run_avito_detail_backfill( LIMIT CAST(:batch_size AS int) ), oblast AS ( - SELECT id, source_url, 'oblast' AS city_scope + SELECT id, source_url, price_rub, 'oblast' AS city_scope FROM listings WHERE source = 'avito' AND detail_enriched_at IS NULL @@ -264,9 +331,9 @@ async def run_avito_detail_backfill( ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST LIMIT CAST(:oblast_batch_size AS int) ) - SELECT id, source_url, city_scope FROM ekb + SELECT id, source_url, price_rub, city_scope FROM ekb UNION ALL - SELECT id, source_url, city_scope FROM oblast + SELECT id, source_url, price_rub, city_scope FROM oblast """ ), { @@ -305,8 +372,17 @@ async def run_avito_detail_backfill( ) consecutive_blocks = 0 + consecutive_failures = 0 + aborted_by_blocks = False do_sleep = False items_since_warm = 0 + # Перепись причин (блоки + отказы) — переживает пересоздание контейнера, + # в отличие от логов; см. _failure_signature. + failure_census: Counter[str] = Counter() + # #2764: диагнозы всех блоков прогона по ТИПУ исключения. Сойдутся в один — + # он и попадёт в scrape_runs.ban_kind, разойдутся — 'unknown' (схлопывает + # mark_backfill_finished, один узел на все три backfill'а). + block_ban_kinds: set[str] = set() for idx, row in enumerate(snapshot): # Budget guard @@ -425,8 +501,9 @@ async def run_avito_detail_backfill( if use_curl: items_since_warm += 1 consecutive_blocks = 0 + consecutive_failures = 0 - except AvitoListingGoneError: + except AvitoListingGoneError as gone_exc: # #2034: мёртвый листинг (404 / removed) — НЕ блок, НЕ failed. # Координатные дыры в lat-null очереди в основном dead-листинги; # browser-mode рендерит их «Ошибка 404» без item-view → раньше это @@ -436,12 +513,31 @@ async def run_avito_detail_backfill( # и не сбрасываем). Метим is_active=FALSE → листинг уходит из scope # (snapshot SELECT фильтрует is_active = TRUE) и не тратит фетчи впредь. counters.gone += 1 + # 404 — честный ответ площадки, значит тракт цел: серия отказов + # прерывается (блочный брейкер 404 не трогает — см. #2034). + consecutive_failures = 0 + failure_census[_failure_signature(gone_exc)] += 1 try: with db.begin_nested(): db.execute( text("UPDATE listings SET is_active = FALSE WHERE id = :id"), {"id": row["id"]}, ) + # #2674: 404 с площадки — самый достоверный сигнал снятия, + # фиксируем его в дневной истории (listings_snapshots.status + # был константой 'active' у всех строк, 394 299). Тот же + # SAVEPOINT, что и UPDATE флага: снимок без флага (или + # наоборот) невозможен. price_rub из snapshot-SELECT — + # .get() консервативен ради mock-снапшотов старых тестов. + gone_price = row.get("price_rub") + if gone_price is not None: + upsert_listing_snapshot( + db, + listing_id=row["id"], + price_rub=gone_price, + run_id=run_id, + status="closed", + ) except Exception: logger.warning( "avito_detail_backfill: run_id=%d failed to mark listing %s " @@ -462,6 +558,8 @@ async def run_avito_detail_backfill( except (AvitoBlockedError, AvitoRateLimitedError) as e: consecutive_blocks += 1 counters.blocked += 1 + failure_census[_failure_signature(e)] += 1 + block_ban_kinds.add(ban_kind_of_exception(e)) do_sleep = False logger.warning( "avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", @@ -482,6 +580,7 @@ async def run_avito_detail_backfill( counters.enriched, counters.attempted, ) + aborted_by_blocks = True break # МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено: # 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную @@ -518,12 +617,14 @@ async def run_avito_detail_backfill( exc_info=True, ) - except TimeoutError: + except TimeoutError as e: # asyncio.wait_for → TimeoutError (py3.12: asyncio.TimeoutError — alias). # Ловим ДО общего Exception (TimeoutError ⊂ OSError ⊂ Exception). Зависший # fetch отменён → листинг failed, переходим к следующему (loop не зависает, # run не zombie #1950). Не считаем soft-блоком: rotate не дёргаем. counters.failed += 1 + consecutive_failures += 1 + failure_census[_failure_signature(e)] += 1 logger.warning( "avito_detail_backfill: run_id=%d listing %s TIMEOUT (>%.0fs) -- skip", run_id, @@ -537,6 +638,8 @@ async def run_avito_detail_backfill( except Exception as e: counters.failed += 1 + consecutive_failures += 1 + failure_census[_failure_signature(e)] += 1 logger.warning( "avito_detail_backfill: run_id=%d listing %s failed: %s", run_id, @@ -548,15 +651,35 @@ async def run_avito_detail_backfill( except Exception: pass + if consecutive_failures >= max_consecutive_failures: + logger.error( + "avito_detail_backfill: run_id=%d ABORT -- %d отказов подряд без " + "единого успеха, частая причина: %s. enriched=%d attempted=%d", + run_id, + consecutive_failures, + _top_failure(failure_census) or "неизвестна", + counters.enriched, + counters.attempted, + ) + break + if counters.attempted % 25 == 0: current_counters = counters.to_dict() runs_mod.update_heartbeat(db, run_id, current_counters) counters.duration_sec = time.monotonic() - start current_counters = counters.to_dict() - runs_mod.mark_done(db, run_id, current_counters) + runs_mod.mark_backfill_finished( + db, + run_id, + current_counters, + source="avito_detail_backfill", + aborted_by_blocks=aborted_by_blocks, + fail_hint=_top_failure(failure_census), + ban_kinds=block_ban_kinds, + ) logger.info( - "avito_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " + "avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "blocked=%d gone=%d failed=%d duration=%.1fs", run_id, counters.attempted, diff --git a/tradein-mvp/backend/app/tasks/backfill_house_coords_from_listings.py b/tradein-mvp/backend/app/tasks/backfill_house_coords_from_listings.py new file mode 100644 index 00000000..6036f869 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/backfill_house_coords_from_listings.py @@ -0,0 +1,323 @@ +"""Перенос координат из объявлений в дом, когда объявления между собой согласны (#2771). + +ЗАЧЕМ. Дом получает координаты РОВНО ОДИН РАЗ — в момент создания, из породившего его +объявления (`matching/houses.py`). Объявление без координат создаёт дом без координат; +позже объявление геокодируется (`geoportal_coords_backfill`, `geocode_missing_listings` — +обе живые ежедневные задачи), но пишут они в `listings`, а `ON CONFLICT` у вставки дома +обновляет только `address`. Обратно в дом координаты не возвращались никогда: из 52 +источников расписания `houses.lat`/`houses.lon` не писал ни один. Замер на проде 2026-08-07: +1945 домов из 9625 (20.2%) без координат, при этом у 1490 из них объявления с координатами +УЖЕ ЕСТЬ. Эта задача — недостающее звено проводки, и именно ПОВТОРЯЮЩЕЕСЯ: однократный +скрипт закрыл бы дыру на день, а приток новых домов без координат никуда не девается. + +ЧТО ДЕЛАЕТ. Для каждого дома с lat IS NULL берёт geom всех связанных объявлений, считает +центроид и РАЗБРОС — максимальное расстояние от центроида до объявления (ST_DistanceSphere, +метры на сфере). Разброс <= max_spread_m → центроид пишется в дом; иначе дом НЕ трогается +и попадает в счётчик skipped_scattered. + +ПОРОГ 125 м — почему столько (замер на проде 2026-08-07, ST_DistanceSphere, не приближение +«градусы × 111320»). Гистограмма разброса по 258 домам с >1 различной точкой не даёт обрыва: +плотное ядро 0-125 м (150 домов), затем ровное плато ~4-5 домов на каждые 25 м вплоть до +400 м, и масса из 67 домов дальше 400 м. Раз в данных обрыва нет, порог берётся не из них, +а из ПОТРЕБИТЕЛЯ: гео-ограждение `house_dedup_merge` (#2187) считает два дома одним зданием +при ST_DistanceSphere <= 250 м. Записанный сюда центроид — оценка со своей погрешностью +ровно в этот разброс; если разрешить каждому дому ошибаться на все 250 м, два дома с +максимальной ошибкой разъедутся на 500 м, и ограждение отвергнет верную пару (или сведёт +неверную). Отдаём своей погрешности ПОЛОВИНУ бюджета ограждения — 125 м. Цена решения +мала и потому оно не несущее: 125 м пропускает 1382 дома, 250 м — 1403, разница 21 дом, +и это не потеря, а отсрочка (дом остаётся NULL и попадёт в следующий прогон, если его +объявления сойдутся). + +ЧЕГО НЕ ДЕЛАЕТ. Не обещает роста числа слияний дублей. Сейчас гео-ограждение отбраковывает +проигравших с NULL geom, потому что проверить тождество нечем; после переноса у него +появляется ВОЗМОЖНОСТЬ оценить пару — часть сольётся, часть будет отвергнута правильно +(дома в разных городах). Ни то ни другое здесь не предрешается. + +ДОМА С БОЛЬШИМ РАЗБРОСОМ — не задача переноса, а дефект сопоставителя: в одну запись дома +сшиты объявления из разных населённых пунктов (худший на проде — «ул. Кирова,4», разброс +290 км). Они не переносятся, считаются отдельным счётчиком и логируются поимённо (топ-5), +чтобы пропуск был виден, а не читался как «покрыто всё». + +ИДЕМПОТЕНТНОСТЬ. UPDATE только по `h.lat IS NULL` (тот же приём, что в +`backfill_listings_coords_geoportal`). Повторный прогон не трогает уже проставленные +координаты и на чистой базе — no-op с нулевыми счётчиками. + +geom СТРОИТСЯ ЯВНО. Триггер `houses_set_geom_trg` на проде — BEFORE INSERT OR UPDATE OF +lat, lon (проверено 2026-08-07), то есть geom построился бы и сам; UPDATE всё равно пишет +geom явно — как это делает `_update_listing_coords` в geoportal-задаче — чтобы правка не +зависела от живости триггера и не оказалась беззвучной, если его когда-нибудь снимут. + +Запуск: + python -m app.tasks.backfill_house_coords_from_listings + python -m app.tasks.backfill_house_coords_from_listings --max-spread-m 125 +Ежедневно через in-app scheduler (source='house_coords_from_listings', migration 239). +""" + +from __future__ import annotations + +import argparse +import logging +import time +from dataclasses import dataclass, field + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import SessionLocal +from app.services import scrape_runs as runs_mod + +logger = logging.getLogger(__name__) + +# Половина 250-метрового гео-ограждения house_dedup_merge (#2187) — обоснование в docstring. +DEFAULT_MAX_SPREAD_M = 125.0 + +# «Разброс в километрах» — порог отдельного счётчика для дефектов сопоставителя (#2777). +SCATTER_DEFECT_M = 5000.0 + +# Сколько худших домов с разбросом назвать поимённо в логе. +_SCATTER_LOG_TOP = 5 + +# Кандидаты: КАЖДЫЙ дом без координат + агрегат по его объявлениям. LEFT JOIN, чтобы +# «объявлений нет вовсе» и «объявления есть, но без координат» различались в счётчиках. +# ST_Collect/count(geom) пропускают NULL — дом без геокодированных объявлений получает +# cen IS NULL → lat/lon NULL → в UPDATE не попадает (n_geocoded = 0). +_BUILD_CANDIDATES_SQL = """ +CREATE TEMP TABLE _2771_house_coords ON COMMIT DROP AS +WITH coordless AS ( + SELECT id FROM houses WHERE lat IS NULL OR lon IS NULL +), +linked AS ( + SELECT c.id AS house_id, l.id AS listing_id, l.geom + FROM coordless c + LEFT JOIN listings l ON l.house_id_fk = c.id +), +agg AS ( + SELECT house_id, + count(listing_id) AS n_listings, + count(geom) AS n_geocoded, + ST_Centroid(ST_Collect(geom)) AS cen + FROM linked + GROUP BY house_id +), +spread AS ( + SELECT l.house_id, + max(ST_DistanceSphere(l.geom, a.cen)) AS spread_m + FROM linked l + JOIN agg a ON a.house_id = l.house_id + WHERE l.geom IS NOT NULL + GROUP BY l.house_id +) +SELECT a.house_id, + a.n_listings, + a.n_geocoded, + ST_Y(a.cen) AS lat, + ST_X(a.cen) AS lon, + s.spread_m +FROM agg a +LEFT JOIN spread s ON s.house_id = a.house_id +""" + +_COUNTERS_SQL = """ +SELECT count(*) AS candidates, + count(*) FILTER (WHERE n_listings = 0) AS skipped_no_listings, + count(*) FILTER (WHERE n_listings > 0 AND n_geocoded = 0) AS skipped_no_listing_coords, + count(*) FILTER (WHERE n_geocoded > 0 + AND spread_m > CAST(:max_spread_m AS double precision)) + AS skipped_scattered, + count(*) FILTER (WHERE n_geocoded > 0 + AND spread_m > CAST(:defect_m AS double precision)) + AS scattered_over_5km, + COALESCE(max(spread_m) FILTER (WHERE n_geocoded > 0 + AND spread_m > CAST(:max_spread_m AS double precision)), 0) + AS worst_spread_m, + count(*) FILTER (WHERE n_geocoded > 0 + AND spread_m <= CAST(:max_spread_m AS double precision)) + AS eligible +FROM _2771_house_coords +""" + +# Идемпотентность: h.lat IS NULL повторяется в UPDATE (а не только в отборе кандидатов) — +# уже проставленные координаты не перезаписываются ни при каком стечении обстоятельств. +_APPLY_SQL = """ +UPDATE houses h +SET lat = c.lat, + lon = c.lon, + geom = ST_SetSRID(ST_MakePoint(c.lon, c.lat), 4326) +FROM _2771_house_coords c +WHERE h.id = c.house_id + AND h.lat IS NULL + AND c.n_geocoded > 0 + AND c.spread_m <= CAST(:max_spread_m AS double precision) +""" + +_SCATTER_TOP_SQL = """ +SELECT c.house_id, h.address, c.n_geocoded, c.spread_m +FROM _2771_house_coords c +JOIN houses h ON h.id = c.house_id +WHERE c.n_geocoded > 0 + AND c.spread_m > CAST(:max_spread_m AS double precision) +ORDER BY c.spread_m DESC +LIMIT CAST(:top AS integer) +""" + + +@dataclass +class HouseCoordsResult: + """Счётчики прогона. Пропуск виден отдельно по КАЖДОЙ причине — молчаливое усечение + читается как «покрыто всё».""" + + candidates: int = 0 # домов без координат всего + eligible: int = 0 # объявления согласны (разброс <= порога) + updated: int = 0 # реально обновлено (UPDATE rowcount) + skipped_no_listings: int = 0 # объявлений у дома нет вовсе + skipped_no_listing_coords: int = 0 # объявления есть, но сами без координат + skipped_scattered: int = 0 # разброс больше порога — дефект сопоставителя (#2777) + scattered_over_5km: int = 0 # из них разъехавшиеся на километры + worst_spread_m: int = 0 # худший пропущенный разброс, метры + max_spread_m: int = 0 # применённый порог, метры (чтобы счётчики читались без кода) + duration_sec: float = field(default=0.0) + + def to_counters(self) -> dict[str, int]: + return { + "candidates": self.candidates, + "eligible": self.eligible, + "updated": self.updated, + "skipped_no_listings": self.skipped_no_listings, + "skipped_no_listing_coords": self.skipped_no_listing_coords, + "skipped_scattered": self.skipped_scattered, + "scattered_over_5km": self.scattered_over_5km, + "worst_spread_m": self.worst_spread_m, + "max_spread_m": self.max_spread_m, + "duration_sec": int(self.duration_sec), + } + + +def backfill_house_coords( + db: Session, + *, + max_spread_m: float = DEFAULT_MAX_SPREAD_M, +) -> HouseCoordsResult: + """Перенести координаты объявлений в дома, где объявления согласны. + + Одна транзакция: TEMP-таблица кандидатов (ON COMMIT DROP) → счётчики → UPDATE. + Счётчики считаются по ТОЙ ЖЕ таблице, из которой идёт UPDATE, поэтому «сколько + пропущено» относится ровно к этому прогону, а не к соседнему запросу. + + Args: + db: сессия tradein-БД. + max_spread_m: предел разброса объявлений, метры (см. DEFAULT_MAX_SPREAD_M). + + Returns: + HouseCoordsResult со счётчиками по каждой причине пропуска. + """ + start = time.monotonic() + res = HouseCoordsResult(max_spread_m=int(max_spread_m)) + params = {"max_spread_m": max_spread_m, "defect_m": SCATTER_DEFECT_M} + + db.execute(text(_BUILD_CANDIDATES_SQL)) + + row = db.execute(text(_COUNTERS_SQL), params).mappings().one() + res.candidates = int(row["candidates"]) + res.eligible = int(row["eligible"]) + res.skipped_no_listings = int(row["skipped_no_listings"]) + res.skipped_no_listing_coords = int(row["skipped_no_listing_coords"]) + res.skipped_scattered = int(row["skipped_scattered"]) + res.scattered_over_5km = int(row["scattered_over_5km"]) + res.worst_spread_m = int(row["worst_spread_m"]) + + if res.skipped_scattered: + worst = ( + db.execute(text(_SCATTER_TOP_SQL), {**params, "top": _SCATTER_LOG_TOP}).mappings().all() + ) + logger.warning( + "house_coords: ПРОПУЩЕНО %d домов с разбросом объявлений > %.0f м " + "(из них %d дальше %.0f км) — это дефект сопоставителя (#2777), не переноса. " + "Худшие: %s", + res.skipped_scattered, + max_spread_m, + res.scattered_over_5km, + SCATTER_DEFECT_M / 1000, + "; ".join( + f"id={w['house_id']} {w['address']!r} n={w['n_geocoded']} " + f"разброс={w['spread_m'] / 1000:.1f} км" + for w in worst + ), + ) + + res.updated = int(db.execute(text(_APPLY_SQL), params).rowcount or 0) + db.commit() + + res.duration_sec = time.monotonic() - start + logger.info( + "house_coords: DONE — кандидатов=%d годных=%d обновлено=%d | пропущено: " + "без_объявлений=%d объявления_без_координат=%d разброс>%.0fм=%d (худший %d м) " + "время=%.1fs", + res.candidates, + res.eligible, + res.updated, + res.skipped_no_listings, + res.skipped_no_listing_coords, + max_spread_m, + res.skipped_scattered, + res.worst_spread_m, + res.duration_sec, + ) + return res + + +# ── Run lifecycle wrapper (scheduler entrypoint) ───────────────────────────── +def run_house_coords_from_listings( + db: Session, + *, + run_id: int, + params: dict, +) -> HouseCoordsResult: + """Run-lifecycle обёртка (kit scheduler, source='house_coords_from_listings'). + + Params (default_params jsonb): + max_spread_m: предел разброса в метрах (default 125). + """ + max_spread_m = float(params.get("max_spread_m", DEFAULT_MAX_SPREAD_M)) + counters: dict[str, int] = HouseCoordsResult(max_spread_m=int(max_spread_m)).to_counters() + + try: + runs_mod.update_heartbeat(db, run_id, counters) + res = backfill_house_coords(db, max_spread_m=max_spread_m) + counters = res.to_counters() + runs_mod.mark_done(db, run_id, counters) + return res + except Exception as exc: + logger.exception("run_house_coords_from_listings: run_id=%d FAILED", run_id) + try: + db.rollback() + except Exception: + logger.warning("run_house_coords_from_listings: rollback failed", exc_info=True) + runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) + raise + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + parser = argparse.ArgumentParser( + description="Перенос координат объявлений в дома, где объявления согласны (#2771)" + ) + parser.add_argument( + "--max-spread-m", + type=float, + default=DEFAULT_MAX_SPREAD_M, + help=f"Предел разброса объявлений, метры (default: {DEFAULT_MAX_SPREAD_M:.0f})", + ) + args = parser.parse_args() + + db = SessionLocal() + try: + backfill_house_coords(db, max_spread_m=args.max_spread_m) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tradein-mvp/backend/app/tasks/cadastral_geo_match.py b/tradein-mvp/backend/app/tasks/cadastral_geo_match.py index 3bb58c55..adc21d50 100644 --- a/tradein-mvp/backend/app/tasks/cadastral_geo_match.py +++ b/tradein-mvp/backend/app/tasks/cadastral_geo_match.py @@ -15,9 +15,18 @@ APPROXIMATION (deliberate first increment): This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest cadastral building within `threshold_m`, NOT necessarily its exact cadastral building. The threshold is always logged. Exact cadastral resolution + parcel-containment are - deferred (cad_parcels FDW not exposed). Tier-0 house matching in the estimator already - treats building_cadastral_number as a hint, not ground truth, so an approximate fill is - a net win over 0% coverage. + deferred (cad_parcels FDW not exposed). + + ЭТО HINT, И ТОЛЬКО HINT (#2674 — правка прежнего утверждения в этой шапке). + Раньше здесь было написано, что Tier-0 матчинга домов «уже трактует + building_cadastral_number как подсказку»; это неверно — Tier 0 в + `app/services/matching/houses.py` отдаёт confidence 1.0, т.е. точное совпадение. + Замер на проде 2026-08-05: 656 из 3 260 заполненных здесь значений накрывают более + одного здания ГАР (20.1%), а 751 из 2 864 зданий ГАР получают более одного значения + (26.2%) — ключ не инъективен ни в одну сторону. Поэтому подавать эту колонку в Tier 0 + (ни на пере-скрейпе, ни бэкфиллом в houses.cadastral_number) НЕЛЬЗЯ: это склеит разные + здания с максимальной уверенностью. Колонка годится как признак/подсказка, не как + идентичность здания. Pipeline (one combined run, scheduler source='cadastral_geo_match'): 1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan). diff --git a/tradein-mvp/backend/app/tasks/cian_history_backfill.py b/tradein-mvp/backend/app/tasks/cian_history_backfill.py index d76f2be9..2cb69680 100644 --- a/tradein-mvp/backend/app/tasks/cian_history_backfill.py +++ b/tradein-mvp/backend/app/tasks/cian_history_backfill.py @@ -18,6 +18,14 @@ Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column). Rate limit: scraper_settings.get_scraper_delay('cian') between requests. + +Сигнал живости (#2725): батч дёргает `on_progress` на КАЖДОЙ сущности — caller +переливает это в scrape_runs.heartbeat_at. Пока колбэка не было, планировщик слал +heartbeat один раз ДО батча, а `reap_zombies` меряет ровно heartbeat с порогом 6 ч — +и добивал живые прогоны строго на 6-м часу (6 прод-прогонов, у пятерых внутри окна +писались строки offer_price_history, у одного — до 5.4 ч после старта). Ослаблять +критерий нельзя: пометка 'zombie' снимает running-блокировку источника +(`has_running_run`), без неё зависший прогон запер бы источник навсегда. """ from __future__ import annotations @@ -25,6 +33,7 @@ from __future__ import annotations import asyncio import logging import time +from collections.abc import Callable from dataclasses import dataclass, field from scraper_kit.browser_fetcher import BrowserFetcher @@ -34,7 +43,11 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings -from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig +from app.services.scraper_adapters import ( + RealMatcherAdapter, + RealProxyProvider, + RealScraperConfig, +) from app.services.scraper_settings import get_scraper_delay logger = logging.getLogger(__name__) @@ -68,6 +81,7 @@ async def backfill_cian_history( do_houses: bool = True, do_valuations: bool = False, dry_run: bool = False, + on_progress: Callable[[CianBackfillResult], None] | None = None, ) -> CianBackfillResult: """Iterate Cian listings + houses with missing history, fetch+save. @@ -82,6 +96,10 @@ async def backfill_cian_history( do_valuations: process Cian Valuation Calculator batch (external_valuations backfill). Default False — opt-in because each call hits Cian auth-gated API. dry_run: skip all fetch+save; only count and log pending rows. + on_progress: колбэк живости (#2725) — вызывается на каждой сущности ЛЮБОГО из + трёх блоков, до её обработки, с текущим (мутируемым) result. Caller пишет + heartbeat; исключения колбэка — на его совести (планировщик глушит их сам), + здесь они прервали бы батч. Returns: CianBackfillResult with per-domain counters + total wall-clock duration. @@ -120,6 +138,8 @@ async def backfill_cian_history( listing_id: int = row["id"] source_url: str = row["source_url"] result.listings_processed += 1 + if on_progress is not None: + on_progress(result) enrichment = None try: @@ -209,10 +229,18 @@ async def backfill_cian_history( house_id: int = hrow["id"] zhk_url: str = hrow["cian_zhk_url"] result.houses_processed += 1 + if on_progress is not None: + on_progress(result) enrichment = None try: - enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig()) + # proxy_provider (#2767): тот же сожжённый env-узел бил и сюда — + # это второй вызывающий fetch_newbuilding, чинить надо оба. + enrichment = await fetch_newbuilding( + zhk_url, + config=RealScraperConfig(), + proxy_provider=RealProxyProvider(), + ) except Exception as exc: logger.warning( "cian_newbuilding fetch failed for house_id=%s url=%s: %s", @@ -291,6 +319,8 @@ async def backfill_cian_history( else: for row in rows: result.valuations_processed += 1 + if on_progress is not None: + on_progress(result) try: cval = await estimate_via_cian_valuation( db, diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index 1d1c2188..cdab053b 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -9,6 +9,9 @@ TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем. - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. - Строки НЕ удаляются -- история нужна для бэктеста (#667). +- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом + 'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка + ответила 404) пишет только avito_detail_backfill: смешивать факт с догадкой дорого. Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*), @@ -21,6 +24,7 @@ TTL для avito берётся из settings.avito_stale_ttl_days (env AVITO_ST from __future__ import annotations import logging +from math import ceil from typing import Any from sqlalchemy import text @@ -41,21 +45,227 @@ logger = logging.getLogger(__name__) # только реальный скрейп). _ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"}) +# ── Снимок «протухло» в дневной истории (#2674) ─────────────────────────────── +# listings_snapshots.status до этого фикса был константой 'active' у всех строк +# (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали +# литерал 'active', и это честно: там объявление ДЕЙСТВИТЕЛЬНО видели. А деактивация +# TTL-задачей не оставляла в истории вообще никакого следа. Из-за этого дата снятия +# объявления (лучший доступный сигнал «скорее всего продано») не запрашивалась из +# истории, а восстанавливалась на глаз: последний показ + предполагаемый срок жизни. +# +# ПОЧЕМУ 'stale', А НЕ 'closed'. Эта задача НЕ знает, что объявление снято, — она +# знает только, что МЫ его N суток не видели, а это разные факты, когда TTL короче +# простоя обхода. Замер: прогон по домклику 02.08 снял 6131 объявление за раз (TTL +# 14 суток против 12 суток простоя обхода) — с общим статусом это были бы 6131 +# фальшивая «дата продажи» одной датой. Продукт про цены, смешивать факт с догадкой +# дорого. Поэтому: +# 'closed' — только путь 404: площадка ответила «нет» (avito_detail_backfill); +# 'stale' — этот путь: «мы N суток не смотрели». +# Дата всё равно фиксируется, но читатель отличает одно от другого. Ограничения +# CHECK на колонке нет (проверено на проде), миграция не нужна — только COMMENT. +# +# Пишем снимок в ТОЙ ЖЕ транзакции, что и UPDATE флага: деактивация без снимка (или +# наоборот) невозможна по построению — один statement, data-modifying CTE. +# 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно +# одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются +# в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных. +# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена. +# ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром, +# а вечером сработал TTL) — только переводим статус в 'stale', цену не переписываем. +_STALE_SNAPSHOT_TAIL = """ + INSERT INTO listings_snapshots + (listing_id, snapshot_date, run_id, price_rub, status, observed_at) + SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'stale', NOW() + FROM stale + ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET + status = 'stale', + observed_at = EXCLUDED.observed_at +""" + + +# ── Гейт по здоровью сбора (#2659) ──────────────────────────────────────────── +# TTL отвечает на вопрос «объявление сняли?», а меряет «мы его давно не видели». +# Пока обход здоров, разница мала. Когда обход лёг — разница равна всему инвентарю. +# +# Замер на проде, из-за которого этот гейт существует. Авито 10.07-26.07.2026: +# 17 суток подряд без единой успешно собранной страницы, TTL=10 снял за этот отрезок +# 9 033 строки; 1 270 из них потом доказанно вернулись живыми (снимки +# listing_source_snapshots + текущий last_seen_at) — сбор восстановился, и объявления +# оказались на месте. То есть «мы не смогли зайти» было прочитано как «объявление снято». +# +# ПОЧЕМУ НЕ ПО СТАТУСУ ban. Соблазн взять scrape_runs.status='banned' — ловушка: +# Яндекс 18.07-30.07 — 5 прогонов в сутки, ВСЕ 'done', НОЛЬ 'banned', total_seen=0 +# 13 суток подряд (снято ~839 строк vtorichka); +# Домклик 20.07-30.07 — то же самое, 11 суток 'done' с total_seen=0, а 02.08 TTL +# снял 6 131 строку разом (см. комментарий про 'stale' выше). +# Оба провала для ban-детектора невидимы. Поэтому здоровье меряем НЕ статусом прогона, +# а результатом: сколько строк источник реально подтвердил свежими за последние сутки. +# +# МЕТРИКА: count(*) по той же колонке свежести, что и сам TTL (last_seen_at или +# scraped_at) и по тому же срезу source+segment, что и UPDATE. Одна колонка на обе +# стороны — гейт нельзя обмануть bulk-touch'ем, который не двигает scraped_at (#2204). +# +# ПОРОГ. Ряд «подтверждений за 3 суток» по дням (восстановлен из listing_source_snapshots): +# avito здоровые сутки 3542..6079, провал 10.07-26.07 — 0..970 → порог 1500; +# yandex vtorichka здоровые 897..2206, провал — 0 → порог 500; +# cian vtorichka 748..4329, провала не было → порог 500; +# domklik по scraped_at сейчас 62/3 суток (сбор фактически стоит) → порог 200. +# Пороги живут в default_params расписания (миграция 219), здесь только страховка +# на случай незасеянного расписания. Асимметрия цены ошибки намеренная: пропущенная +# деактивация чинится следующим прогоном, ложная — только повторным сбором, которого +# может не быть. Поэтому при сомнении — пропускаем прогон. +# +# ПОТОЛОК: окно 3 суток годится, пока свипы источника ходят не реже чем раз в 3 дня. +# Источник с более редкой каденцией будет блокироваться всегда — тогда окно нужно +# растить до каденции, а не понижать порог. +_HEALTH_WINDOW_DAYS = 3 + +# Страховка для расписаний без явного min_confirmations в default_params: ловит +# полный ноль и близкое к нулю, но НЕ ловит частичный провал вроде avito 936-970 — +# для этого нужен посчитанный по источнику порог из миграции 219. +DEFAULT_MIN_CONFIRMATIONS = 500 + +_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))" + + +# ── Пол TTL по измеренному циклу переобхода (#2659) ─────────────────────────── +# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на +# вопрос, из-за которого заведён #2659: «а достаточно ли ttl_days, чтобы молчание +# означало снятие?». Пока свип возвращается к строке реже, чем раз в ttl_days, +# TTL меряет НАШУ выборку, а не жизнь объявления, — и источник при этом полностью +# здоров, так что гейт молчит. +# +# ЗАМЕР НА ПРОДЕ 2026-08-09, из-за которого этот пол существует. +# С момента деплоя гейта (06.08) TTL снял 1 028 строк; 127 из них (12.4%) УЖЕ снова +# активны — свип нашёл их живыми через 1-3 суток и вернул сам (upsert в +# scraper_kit/base.py ставит is_active = true). В единственном городе с настоящим +# покрытием доля ложных снятий 100%: +# cian Екатеринбург 103 снято → 103 снова активны +# yandex Екатеринбург 24 снято → 24 снова активны +# cian/yandex без города 901 снято → 0 вернулись (их свип не обходит вовсе) +# Возраст на момент снятия у всех 127: 29.9..30.3 суток при TTL=30 — то есть TTL +# срабатывал ровно на границе, а свип возвращался к строке на 31-34-е сутки. +# +# ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ НОВОЙ КОНСТАНТОЙ. Разрывы переобхода, суток +# (listing_source_snapshots, 40 суток, посчитано по срезу TTL-джобы): +# источник/сегмент p90 p99 TTL сейчас TTL/p99 +# domklik vtorichka 1.9 3.1 14 4.5 ← сплошное суточное покрытие +# cian vtorichka 10.9 26.6 30 1.1 +# yandex vtorichka 5.7 43.0 30 0.7 +# avito vtorichka 29.1 42.1 10 0.24 ← отсюда 9 033 строки +# Домклик — контрольная группа: при почти полном суточном обходе TTL=14 лежит в +# 4.5 раза выше хвоста, и снятие у него действительно означает снятие. У остальных +# трёх порог ниже собственного хвоста обхода — руками подобранное число и есть +# корень #2659, поэтому чинить его вторым руками подобранным числом бессмысленно. +# +# ЧТО МЕРЯЕМ ВМЕСТО КОНСТАНТЫ: факт, а не оценку. «Какой самый большой возраст, при +# котором свип за последнее окно ДОКАЗАЛ, что объявление живо» — то есть насколько +# старую строку он только что нашёл на площадке. Если свип буквально вчера вернул к +# жизни строку, молчавшую 40 суток, то 30 суток молчания не доказывают ничего. +# Пол = квантиль этого распределения, эффективный TTL = max(ttl_days, пол). +# +# Считается по ТОМУ ЖЕ срезу (source + segments) и по ТОЙ ЖЕ колонке свежести, что +# и UPDATE. Предыдущее наблюдение берётся из listing_source_snapshots — единственной +# истории свежести, что у нас есть; расхождение listings. и +# listing_sources.last_seen_at замерено на проде и не превышает 0.5 суток в среднем +# (максимум 0), что на шкале 30-70 суток шум. +# +# КВАНТИЛЬ — калибровочная ручка, не догма. 0.99 подобран по требованию «пол обязан +# накрыть 127 доказанных ложных снятий», у которых возраст был 29.9..30.3: замер +# того же запроса на проде даёт 34.0 для cian/vtorichka и 74.3 для yandex/vtorichka. +# Ниже 0.99 опускать нельзя без нового замера. Ручка живёт в default_params +# расписания (revisit_floor_quantile), 0 -> пол выключен. +# +# ПОБОЧНЫЙ ЭФФЕКТ, КОТОРЫЙ ЗДЕСЬ НАМЕРЕННЫЙ: после провала сбора хвост разрывов +# распухает (свип разгребает завал и находит очень старые строки), пол поднимается, +# и деактивация замирает сама — без отдельного детектора банов. Когда завал разобран, +# хвост схлопывается и пол опускается обратно. Это ровно то поведение, которого +# issue просил от «гейта по банам», но выраженное через результат, а не через причину. +# +# ПОТОЛОК: пол не может превысить глубину истории снимков. Если снимок за нужную +# дату не писался (дыры на проде есть — 30.07, 01.08), берётся ближайший более +# ранний; при полном отсутствии снимков пол не считается и TTL остаётся как задан. +DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 + +_REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))" + + +def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any: + """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива. + + Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт + разрыв переобхода в сутках; берём его квантиль по срезу source+segments. + Только строки, у которых свежесть реально сдвинулась, — то есть выжившие, + а не «мы к ним не приходили». + + 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 "" + return text( + f""" + SELECT percentile_disc(CAST(:revisit_quantile AS double precision)) + WITHIN GROUP ( + ORDER BY EXTRACT(epoch FROM (l.{staleness_column} - prev.last_seen_at)) + / 86400.0 + ) + FROM listings l + JOIN listing_sources ls + ON ls.listing_id = l.id + AND ls.ext_source = l.source + JOIN listing_source_snapshots prev + ON prev.listing_source_id = ls.id + AND prev.snapshot_date = ( + SELECT max(snapshot_date) + FROM listing_source_snapshots + WHERE snapshot_date + <= CURRENT_DATE - CAST(:health_window_days AS integer) + ) + WHERE l.source = :listing_source + AND l.{staleness_column} + > NOW() - CAST(:health_window_days || ' days' AS interval) + AND l.{staleness_column} > prev.last_seen_at{segment_filter} + """ + ) + + +def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any: + """SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE. + + 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 "" + return text( + f""" + SELECT count(*) + FROM listings + WHERE source = :listing_source + AND {staleness_column} + > NOW() - CAST(:health_window_days || ' days' AS interval){segment_filter} + """ + ) + def _build_all_segments_sql(staleness_column: str) -> Any: """UPDATE без фильтра по сегменту: все сегменты для данного source. staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings, поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source, - :ttl_days) остаются param-binding — psycopg v3 safe (никаких :param::type). + :ttl_days, :run_id) остаются param-binding — psycopg v3 safe (никаких :param::type). """ return text( f""" - UPDATE listings - SET is_active = false - WHERE source = :listing_source - AND is_active = true - AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + 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) + RETURNING id, price_rub + ) + {_STALE_SNAPSHOT_TAIL} """ ) @@ -68,12 +278,16 @@ def _build_segments_sql(staleness_column: str) -> Any: """ return text( f""" - 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 = ANY(CAST(:segments AS text[])) + 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 = ANY(CAST(:segments AS text[])) + RETURNING id, price_rub + ) + {_STALE_SNAPSHOT_TAIL} """ ) @@ -95,6 +309,9 @@ def deactivate_stale_listings( ttl_days: int, segments: list[str] | None = None, staleness_column: str = "last_seen_at", + min_confirmations: int = 0, + health_window_days: int = _HEALTH_WINDOW_DAYS, + revisit_floor_quantile: float = 0.0, ) -> dict[str, int]: """Пометить is_active=false объявления, чья свежесть старше ttl_days дней. @@ -109,11 +326,26 @@ def deactivate_stale_listings( last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch двигает last_seen_at всем строкам одним timestamp, поэтому честная свежесть = scraped_at (двигается только реальным скрейпом). + min_confirmations: гейт по здоровью сбора (#2659). Сколько строк источник + должен был подтвердить свежими за health_window_days суток, чтобы + деактивации вообще разрешалось исполниться. 0 -> гейт выключен (так + вызывают старые тесты и совместимая обёртка); реальные значения приходят + из default_params расписания, см. миграцию 219 и комментарий выше. + health_window_days: окно подтверждений для гейта, суток. Дефолт 3. + revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659). + Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой; + эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так + вызывают старые тесты и совместимая обёртка), рабочее значение — + DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше. Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). - Один UPDATE в транзакции. Финализирует scrape_runs (mark_done / mark_failed). + Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots + (data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed). - Returns {"deactivated": N} -- количество обновлённых строк. + Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками). + Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N, + "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял + TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Raises: ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, @@ -131,6 +363,82 @@ def deactivate_stale_listings( f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}" ) + # Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима + # на практике (вернуть «живость» может только повторный сбор), поэтому + # проверяем ПЕРЕД записью, а не откатываем после. + if min_confirmations > 0: + health_params: dict[str, Any] = { + "listing_source": listing_source, + "health_window_days": health_window_days, + } + if segments is not None: + health_params["segments"] = segments + confirmations = ( + db.execute( + _build_confirmations_sql(staleness_column, with_segments=segments is not None), + health_params, + ).scalar() + or 0 + ) + counters["confirmations"] = int(confirmations) + if confirmations < min_confirmations: + counters["skipped_unhealthy"] = 1 + # Ничего не писали (был только SELECT) — rollback закрывает транзакцию + # чисто, чтобы mark_done стартовал со своей. + db.rollback() + runs_mod.mark_done(db, run_id, counters) + logger.warning( + "deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — " + "подтверждений за %d сут %d < порога %d " + "(segments=%r, staleness_column=%s); ни одна строка не тронута", + listing_source, + run_id, + health_window_days, + confirmations, + min_confirmations, + segments, + staleness_column, + ) + return counters + + # Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же + # срезу. Поднимает порог, никогда не опускает: max(), а не замена. + effective_ttl_days = ttl_days + if revisit_floor_quantile > 0: + floor_params: dict[str, Any] = { + "listing_source": listing_source, + "health_window_days": health_window_days, + "revisit_quantile": revisit_floor_quantile, + } + if segments is not None: + floor_params["segments"] = segments + floor_days = db.execute( + _build_revisit_floor_sql(staleness_column, with_segments=segments is not None), + 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"]) + counters["ttl_days_effective"] = effective_ttl_days + if effective_ttl_days > ttl_days: + logger.warning( + "deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: " + "свип за %d сут доказал живой строку, молчавшую %d сут " + "(квантиль %.3f, segments=%r) — при ttl_days=%d снятие означало бы " + "«мы не дошли», а не «объявление снято»", + listing_source, + run_id, + ttl_days, + effective_ttl_days, + health_window_days, + counters["revisit_floor_days"], + revisit_floor_quantile, + segments, + ttl_days, + ) + # segments is None -> все сегменты (поведение avito). segments=[...] -> только # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), @@ -138,12 +446,17 @@ def deactivate_stale_listings( if segments is not None: params: dict[str, Any] = { "listing_source": listing_source, - "ttl_days": ttl_days, + "ttl_days": effective_ttl_days, "segments": segments, + "run_id": run_id, } result = db.execute(_build_segments_sql(staleness_column), params) else: - params = {"listing_source": listing_source, "ttl_days": ttl_days} + params = { + "listing_source": listing_source, + "ttl_days": effective_ttl_days, + "run_id": run_id, + } result = db.execute(_build_all_segments_sql(staleness_column), params) counters["deactivated"] = result.rowcount or 0 @@ -152,10 +465,11 @@ def deactivate_stale_listings( runs_mod.mark_done(db, run_id, counters) logger.info( "deactivate_stale source=%s run_id=%d done: deactivated=%d " - "(ttl_days=%d, segments=%r, staleness_column=%s)", + "(ttl_days=%d эффективный, задан %d, segments=%r, staleness_column=%s)", listing_source, run_id, counters["deactivated"], + effective_ttl_days, ttl_days, segments, staleness_column, diff --git a/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py b/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py index b585551c..db7440c1 100644 --- a/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py @@ -142,7 +142,9 @@ def check_deals_freshness( row = db.execute(_LATEST_DEAL_DATE_SQL).first() latest: date | None = row.latest if row is not None else None if latest is None: - logger.warning( + # ERROR (#2674): монитор не может выполнить работу — сбой, а не наблюдение. + # Соседняя ветка (overdue) писала ERROR с самого начала; эта расходилась. + logger.error( "deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя" ) runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters) diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 319e9f95..0e304651 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -36,12 +36,19 @@ one BrowserFetcher is constructed per run. Exception triad differs from Avito: - DomClickBlockedError (QRATOR challenge page OR any browser-fetch failure) -- - increments consecutive_blocks, abort via mark_done (NOT mark_failed) once - max_consecutive_blocks is hit -- a block-abort is an expected operational - outcome (QRATOR reputation burn), not a task failure. Mirrors Avito's - AvitoBlockedError handling. No IP-rotation/cooldown recovery step exists here + increments consecutive_blocks, abort once max_consecutive_blocks is hit. + Статус такого прогона — '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. + ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и + получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на + распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch + (наш тракт) -- см. providers/domclick/detail.py::fetch_detail. Пока эти два + случая не разведены отдельным подтипом (как AvitoSidecarUnavailableError у + avito), любой диагноз отсюда был бы назначенным, а не установленным. - DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a block) -- counted as failed++, logged, does NOT touch consecutive_blocks and does NOT abort the run (neutral to the block-breaker, mirrors how Avito's @@ -52,10 +59,16 @@ Exception triad differs from Avito: Cookie injection is mandatory wiring, not optional: cookies are loaded ONCE per run. If None (no valid session uploaded / expired) -- the run still proceeds (cookie- injection is a QRATOR-defeat mechanism, not a hard requirement; organic SERP-origin -navigation from PR #2430 still applies) but a warning is logged once at run start so +navigation from PR #2430 still applies) but an ERROR is logged once at run start so operators notice the test-account session needs refreshing via `POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation, see app/services/domclick_session.py module docstring). + +#2674: раньше это был WARNING, который в скрапер-контейнере событием не становится +(LoggingIntegration event_level=ERROR) — куки протухли 2026-08-03 и об этом никто не +узнал. Теперь два сигнала вместо одного: ERROR по факту (_alert_domclick_cookies) и +ERROR ЗАРАНЕЕ, пока куки ещё живы (_warn_before_domclick_cookies_expire) — по образцу +#2658 для Циана, ручное обновление кук требует запаса времени. """ from __future__ import annotations @@ -65,6 +78,7 @@ import logging import random import time from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError @@ -85,6 +99,60 @@ __all__ = [ ] +def _alert_domclick_cookies(db: Session, run_id: int) -> None: + """Громкий сигнал «обогащение идёт без кук» — logger.error, не warning (#2674). + + В контейнере скрапера GlitchTip поднят с LoggingIntegration(event_level=ERROR) + (scheduler_main.py), поэтому прежний WARNING событием не становился: куки протухли + на проде 2026-08-03, и единственным следом была строка в docker-логе, которая + теряется при редеплое. Прогон при этом НЕ прерываем — cookie-инъекция это + механизм обхода QRATOR, а не жёсткое требование (см. докстринг модуля), — но + состояние требует ручного действия человека, значит должно быть событием. + + Причину различаем так же, как #2658 у Циана: «кук нет вовсе» и «протухли N дней + назад» лечатся одинаково, но диагностируются по-разному. + """ + expires_at = domclick_session_svc.session_expires_at(db) + now = datetime.now(tz=UTC) + if expires_at is None: + detail = "кук DomClick нет в БД" + elif expires_at <= now: + detail = ( + f"куки DomClick протухли {expires_at:%Y-%m-%d} " + f"({(now - expires_at).days} дн. назад)" + ) + else: + detail = "куки DomClick помечены невалидными (last_invalid_at)" + logger.error( + "domclick_detail_backfill: run_id=%d — %s; обогащение идёт БЕЗ cookie-инъекции " + "(QRATOR-обход деградировал до organic SERP-origin навигации, PR #2430). " + "Перезалейте сессию test-аккаунта: POST /scrape/domclick/upload-cookies", + run_id, + detail, + ) + + +def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None: + """Предупредить ЗАРАНЕЕ, пока куки ещё рабочие (#2674, образец — #2658 для Циана). + + Сигнал по факту протухания приходит, когда обогащение уже встало; обновление кук + ручное, человеку нужен запас. valid_only=True — срок ИМЕННО той записи, которую + взял load_session (при нескольких аккаунтах свежайшая-любая может быть чужой). + """ + expires_at = domclick_session_svc.session_expires_at(db, valid_only=True) + if expires_at is None: + return + left = expires_at - datetime.now(tz=UTC) + if left <= timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS): + logger.error( + "domclick_detail_backfill: run_id=%d — куки DomClick протухнут %s " + "(осталось %.1f дн.); обновите заранее, иначе обогащение деградирует молча", + run_id, + expires_at.date().isoformat(), + left.total_seconds() / 86400, + ) + + @dataclass class DomClickDetailBackfillResult: """Counters for one backfill run.""" @@ -120,7 +188,8 @@ async def run_domclick_detail_backfill( max_consecutive_blocks: int -- abort threshold, default 3. Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> - mark_done (incl. partial/block-abort) / mark_failed (exception only). + mark_backfill_finished (done / banned при блоках / failed при нуле, #2674); + mark_failed напрямую — только при исключении. """ batch_size = int(params.get("batch_size", 200)) budget_sec = float(params.get("budget_sec", 3600)) @@ -135,16 +204,13 @@ async def run_domclick_detail_backfill( try: # Cookie injection (#2000 PR #2433) -- loaded ONCE per run, threaded into every - # fetch_detail() call below. None is a valid (degraded) state, not an error. + # fetch_detail() call below. Прогон продолжается и без кук (см. докстринг), но + # это состояние требует ЧЕЛОВЕКА: обновление сессии — ручная операция. cookies = domclick_session_svc.load_session(db) if cookies is None: - logger.warning( - "domclick_detail_backfill: run_id=%d -- no valid DomClick session cookies " - "in DB; proceeding WITHOUT cookie-injection (QRATOR-defeat degraded to " - "organic SERP-origin navigation only, PR #2430). Refresh test-account " - "session via POST /scrape/domclick/upload-cookies.", - run_id, - ) + _alert_domclick_cookies(db, run_id) + else: + _warn_before_domclick_cookies_expire(db, run_id) runs_mod.update_heartbeat(db, run_id, current_counters) @@ -193,6 +259,7 @@ async def run_domclick_detail_backfill( ) consecutive_blocks = 0 + aborted_by_blocks = False do_sleep = False # Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see @@ -275,6 +342,7 @@ async def run_domclick_detail_backfill( counters.enriched, counters.attempted, ) + aborted_by_blocks = True break except Exception as e: @@ -296,9 +364,15 @@ async def run_domclick_detail_backfill( counters.duration_sec = time.monotonic() - start current_counters = counters.to_dict() - runs_mod.mark_done(db, run_id, current_counters) + runs_mod.mark_backfill_finished( + db, + run_id, + current_counters, + source="domclick_detail_backfill", + aborted_by_blocks=aborted_by_blocks, + ) logger.info( - "domclick_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " + "domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "blocked=%d failed=%d duration=%.1fs", run_id, counters.attempted, diff --git a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py index 47044bc2..81dcbf16 100644 --- a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py +++ b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py @@ -104,13 +104,51 @@ _SNAPSHOT_SQL = text( """ ) -# ── Event diff: price_change ────────────────────────────────────────────────── -# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с -# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась -# (обе NOT NULL, old <> 0) — пишем price_change. +# ── Event diff: три выводимых типа событий из пяти в схеме ──────────────────── +# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с +# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE). # today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции). # p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607). # +# #2674: схема (079) знает пять типов событий, писатель умел один — price_change, +# 8288 строк. Дописаны два: +# edited — payload_hash изменился, а цена нет (изменение цены уже описано +# отдельным событием price_change — дублировать его как «редактирование» +# значило бы считать одно изменение дважды). Прошлый хеш обязан быть +# непустым: md5(NULL) = NULL, и «payload появился впервые» — это не +# правка, а первое наблюдение; +# first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL). +# +# delisted и relisted НЕ ПИШУТСЯ НАМЕРЕННО — они НЕ ВЫВОДИМЫ из наших данных. +# is_active в снимке — derived-признак «last_seen_at свежее FRESHNESS_WINDOW_DAYS», +# то есть «мы видели», а не «объявление есть на площадке». При покрытии обхода 10-35% +# такой переход рождается тем, что скрейпер СНОВА ДОШЁЛ до источника, а не тем, что +# объявление вернулось/ушло. Контрольная группа в наших же данных (14-18.07): +# domklik, покрытие 99.9-100%: снятий 1/2/0/2/4 в сутки, возвратов — РОВНО 0 все дни; +# yandex, покрытие 34-43%: снятий 343-433 в сутки, возвратов до 155. +# Тот же обход, тот же день — разница только в покрытии. Отсюда же всплески: +# avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против контрольной +# ставки 1-4, точность события ≈4%; 4705 «возвратов» из 5493 за 12 дней (86%) — это +# два дня после возобновления обхода 2-3.08. +# Сузить окно свежести НЕ поможет — станет хуже (больше флапаний); окно шире +# максимального интервала повторного визита обессмысливает само событие. +# Честный ответ схеме — не писать эти два типа, а не наполнять журнал догадками. +# Единственный жёсткий сигнал снятия — 404 при поштучном обходе, он пишется в +# listings_snapshots.status='closed' (avito_detail_backfill). +# +# Оставшиеся три события утверждают факты о НАШИХ СОБСТВЕННЫХ строках («появился новый +# источник», «хеш изменился при той же цене», «цена другая»), а не о поведении площадки. +# +# JOIN → LEFT JOIN LATERAL: без LEFT источники без предыдущего снимка отбрасывались +# join'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется — +# LEFT JOIN LATERAL так же форсирует per-row индексный point-lookup по +# idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей. +# +# Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт +# до трёх строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это +# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто три предиката +# вместо одного. +# # #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM # listing_source_snapshots WHERE snapshot_date < CURRENT_DATE` и джойнился обычным JOIN. # Планировщик оценивает `today` в 1 строку (свежевставленные в этой же транзакции строки @@ -127,38 +165,79 @@ _SNAPSHOT_SQL = text( # # Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL # — это внутренний план Postgres, не Python-итерация). -# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type) -# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным. +# +# change_time = date_trunc('day', now()), а НЕ now() (#2674): с now() уникальность +# UNIQUE(listing_source_id, change_time, event_type) работала только ВНУТРИ прогона — +# второй прогон в те же сутки перезаписывал сегодняшний снимок, предикаты срабатывали +# заново с другим временем и давали дубли (2 августа таких прогонов было два). +# Суточная гранулярность честнее для суточного же сравнения и включает заявленную +# идемпотентность: ON CONFLICT DO NOTHING теперь действительно гасит повтор за день. +# +# NULLIF(p.price_rub, 0) в diff_percent обязателен: выражения VALUES вычисляются ДО +# фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже +# не спасает — без NULLIF первый же источник с нулевой прошлой ценой уронил бы весь +# прогон. Результат при этом тот же: строка с NULL-диффом не проходит e.fires. +# +# Внешний SELECT над data-modifying CTE считает вставленное ПО ТИПАМ (RETURNING отдаёт +# только реально вставленные строки, не съеденные ON CONFLICT), сразу в виде ключей +# счётчиков `_events` — писатель получает готовый dict без Python-агрегации. +# Ровно этот счётчик и показал бы четыре нуля из пяти, если бы существовал раньше. _EVENT_DIFF_SQL = text( """ WITH today AS ( - SELECT listing_source_id, price_rub + SELECT listing_source_id, price_rub, payload_hash FROM listing_source_snapshots WHERE snapshot_date = CURRENT_DATE + ), + inserted AS ( + INSERT INTO listing_source_events ( + listing_source_id, change_time, event_type, price_rub, diff_percent + ) + SELECT + t.listing_source_id, + date_trunc('day', now()), + e.event_type, + t.price_rub, + e.diff_percent + FROM today t + LEFT JOIN LATERAL ( + SELECT s.snapshot_date, s.price_rub, s.payload_hash + FROM listing_source_snapshots s + WHERE s.listing_source_id = t.listing_source_id + AND s.snapshot_date < CURRENT_DATE + ORDER BY s.snapshot_date DESC + LIMIT 1 + ) p ON true + CROSS JOIN LATERAL (VALUES + ( + 'first_seen', + NULL::numeric, + p.snapshot_date IS NULL + ), + ( + 'price_change', + round((t.price_rub - p.price_rub)::numeric + / NULLIF(p.price_rub, 0) * 100, 4), + t.price_rub IS NOT NULL + AND p.price_rub IS NOT NULL + AND p.price_rub <> 0 + AND t.price_rub <> p.price_rub + ), + ( + 'edited', + NULL::numeric, + p.payload_hash IS NOT NULL + AND t.payload_hash IS DISTINCT FROM p.payload_hash + AND t.price_rub IS NOT DISTINCT FROM p.price_rub + ) + ) AS e(event_type, diff_percent, fires) + WHERE e.fires + ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING + RETURNING event_type ) - INSERT INTO listing_source_events ( - listing_source_id, change_time, event_type, price_rub, diff_percent - ) - SELECT - t.listing_source_id, - now(), - 'price_change', - t.price_rub, - round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4) - FROM today t - JOIN LATERAL ( - SELECT s.price_rub - FROM listing_source_snapshots s - WHERE s.listing_source_id = t.listing_source_id - AND s.snapshot_date < CURRENT_DATE - ORDER BY s.snapshot_date DESC - LIMIT 1 - ) p ON true - WHERE t.price_rub IS NOT NULL - AND p.price_rub IS NOT NULL - AND p.price_rub <> 0 - AND t.price_rub <> p.price_rub - ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING + SELECT event_type || '_events' AS counter_key, count(*) AS n + FROM inserted + GROUP BY 1 """ ) @@ -166,12 +245,14 @@ _EVENT_DIFF_SQL = text( def snapshot_listing_sources( db: Session, run_id: int, params: dict[str, Any] | None = None ) -> dict[str, int]: - """Записать дневной снимок listing_sources + price_change-события. + """Записать дневной снимок listing_sources + события изменений. Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp). Два set-based statement'а в одной транзакции: 1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins. - 2. diff сегодняшней цены против последнего предыдущего снимка → price_change-события. + 2. diff сегодняшнего снимка против последнего предыдущего → три события, + выводимые из наших данных (#2674). delisted/relisted схема разрешает, но + они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL. Params (из default_params jsonb в scrape_schedules, #2607): budget_sec: float — SET LOCAL statement_timeout на транзакцию (default 900, @@ -183,11 +264,18 @@ def snapshot_listing_sources( Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. - Returns {"snapshotted": N, "price_change_events": M}. + Returns {"snapshotted": N, "_events": M} — по счётчику на каждый из + трёх пишущихся типов, всегда все три ключа (тип, который за прогон не сработал + ни разу, честно показывает 0, а не пропадает из counters). """ params = params or {} budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC)) - counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0} + counters: dict[str, int] = { + "snapshotted": 0, + "price_change_events": 0, + "edited_events": 0, + "first_seen_events": 0, + } try: # statement_timeout НЕ принимает bind-параметр ($1/:name) — синтаксис Postgres SET # запрещает placeholder на этом месте (проверено вживую на проде: "syntax error at @@ -205,17 +293,15 @@ def snapshot_listing_sources( ) counters["snapshotted"] = snap_result.rowcount or 0 - event_result = db.execute(_EVENT_DIFF_SQL) - counters["price_change_events"] = event_result.rowcount or 0 + # Statement возвращает уже готовые пары (counter_key, n) по типам событий — + # dict(...) без Python-агрегации, набор ключей задан инициализацией counters + # выше, так что не сработавшие типы остаются нулями, а не исчезают. + event_rows = db.execute(_EVENT_DIFF_SQL).fetchall() + counters.update(dict(event_rows)) db.commit() runs_mod.mark_done(db, run_id, counters) - logger.info( - "snapshot_listing_sources run_id=%d done: snapshotted=%d price_change_events=%d", - run_id, - counters["snapshotted"], - counters["price_change_events"], - ) + logger.info("snapshot_listing_sources run_id=%d done: %s", run_id, counters) return counters except Exception as exc: logger.exception( diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index b6f5dda1..bf71333d 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -66,6 +66,7 @@ import json import logging import random import time +from collections.abc import Callable from dataclasses import dataclass, field, fields from sqlalchemy import text @@ -108,16 +109,44 @@ class NewbuildingEnrichBackfillResult: failed_fetch: int = 0 # fetch returned None / raised failed_save: int = 0 # save raised after a good fetch - # Row-level deltas (how much actually landed). - price_dynamics_rows: int = 0 - reliability_rows: int = 0 - review_rows: int = 0 + # Сколько РЕАЛЬНО записано, по словам самих писателей (#2807). Раньше здесь стоял + # прирост COUNT(*) по таблице до/после сохранения — то есть «выросла ли таблица», а + # не «сколько записали»: при ON CONFLICT DO UPDATE обновление даёт ноль, а у + # reliability ноль давал ещё и _dedup_reliability, схлопывающий дубль сразу после + # вставки. Ключи переименованы намеренно: у price_dynamics_rows/reliability_rows/ + # review_rows в истории прогонов старый смысл, и молча поменять его под тем же + # именем — ровно тот дефект, ради которого правка и делается. + price_dynamics_inserted: int = 0 # новых точек динамики цен + price_dynamics_updated: int = 0 # существующих точек переписано свежей ценой + reliability_inserted: int = 0 # строк house_reliability_checks вставлено + review_upserted: int = 0 # отзывов записано (вставка+обновление, ключ ext_review_id) duration_sec: float = field(default=0.0) def to_dict(self) -> dict[str, int]: return {f.name: int(getattr(self, f.name)) for f in fields(self)} + def to_backfill_counters(self) -> dict[str, int]: + """to_dict() + ключи, которые читает общий финализатор (#2695, #2767). + + runs.mark_backfill_finished смотрит на attempted / enriched / failed; у этой + задачи те же величины называются иначе, поэтому через общий финализатор она не + проходила и ЛЮБОЙ прогон закрывался mark_done — включая 25 попыток с нулём + обогащений восемь суток подряд. + + `attempted` — дома, до которых дошли руки: пропущенный уже-обогащённый домом + попыткой не был, иначе прогон, которому просто нечего делать, выглядел бы + отказом. `blocked` НЕ заводим намеренно: задача блоки источника не различает, + а нулевой прогон без доказанных блоков финализатор помечает 'failed' — наш + тракт, а не «виновата площадка» (#2764: диагноз ставится по доказательству). + """ + return { + **self.to_dict(), + "attempted": self.processed - self.skipped_already_enriched, + "enriched": self.succeeded, + "failed": self.failed_resolve + self.failed_fetch + self.failed_save, + } + # SQL: anchor on the canonical cian_newbuilding link, require a house we can FETCH, and # (unless force) skip houses already enriched. @@ -306,8 +335,7 @@ def _house_enrichment_counts(db: Session, house_id: int) -> tuple[int, int, int] rc = int( db.execute( text( - "SELECT COUNT(*) FROM house_reliability_checks " - "WHERE house_id = CAST(:h AS bigint)" + "SELECT COUNT(*) FROM house_reliability_checks WHERE house_id = CAST(:h AS bigint)" ), {"h": house_id}, ).scalar_one() @@ -328,6 +356,7 @@ async def backfill_newbuilding_enrichment( force: bool = False, request_delay_sec: float | None = None, dry_run: bool = False, + on_progress: Callable[[NewbuildingEnrichBackfillResult], None] | None = None, ) -> NewbuildingEnrichBackfillResult: """Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses. @@ -347,6 +376,11 @@ async def backfill_newbuilding_enrichment( (default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing a resolve incurs TWO delays (resolve fetch + enrich fetch). dry_run: count the population + log the pending list, fetch nothing, write nothing. + on_progress: колбэк живости (#2725) — вызывается на каждом доме с текущим + (мутируемым) result; caller пишет scrape_runs.heartbeat_at. Без него + heartbeat уходил один раз до цикла, а `reap_zombies` меряет именно его: + дом обходится за ~2.6 мин, и на limit'е порядка 140 (полный прогон — 318 + домов, см. выше) прогон переваливал бы 6-часовой порог живым. Returns: NewbuildingEnrichBackfillResult with population sizing, per-house outcome @@ -372,9 +406,18 @@ async def backfill_newbuilding_enrichment( save_newbuilding_enrichment, ) - from app.services.scraper_adapters import RealScraperConfig + from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig scraper_config = RealScraperConfig() + # #2767: обогащение было ЕДИНСТВЕННЫМ cian-путём мимо пула прокси — весь сбор шёл + # через env-узел сайдкара, и когда Циан забанил его exit-IP, 8 суток по 25 попыток + # уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠ + # «включить пул»: реально пул задействуется, только если включён + # config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding). + # #2830: тот же провайдер уходит и в resolve-ногу (curl_cffi, флаг + # use_proxy_pool_curl) — #2767 починил только fetch, а резолв ЖК-url остался на + # статичном cian_proxy_url, то есть на второй ноге той же цепочки. + proxy_provider = RealProxyProvider() result = NewbuildingEnrichBackfillResult() t0 = time.time() @@ -415,6 +458,8 @@ async def backfill_newbuilding_enrichment( zhk_url: str | None = row["cian_zhk_url"] ext_id: str | None = row["ext_id"] result.processed += 1 + if on_progress is not None: + on_progress(result) # Idempotency fast-path: with force=False the SELECT already excludes enriched # houses (price_dynamics + reliability present), so this branch is a belt-and- @@ -449,7 +494,9 @@ async def backfill_newbuilding_enrichment( continue try: - resolved = await resolve_cian_zhk_url_via_search(nb_id, config=scraper_config) + resolved = await resolve_cian_zhk_url_via_search( + nb_id, config=scraper_config, proxy_provider=proxy_provider + ) except Exception as exc: # defensive — resolver already catches internally logger.warning( "zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc @@ -496,7 +543,9 @@ async def backfill_newbuilding_enrichment( # ── Fetch (network; anti-bot surface) ────────────────────────────── enrichment = None try: - enrichment = await fetch_newbuilding(zhk_url, config=scraper_config) + enrichment = await fetch_newbuilding( + zhk_url, config=scraper_config, proxy_provider=proxy_provider + ) except Exception as exc: logger.warning( "newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc @@ -506,8 +555,12 @@ async def backfill_newbuilding_enrichment( continue if enrichment is None: + # Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога + # читается дальше как факт и один раз уже увела диагноз не туда. Причина + # печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers). logger.warning( - "newbuilding fetch returned None house_id=%s url=%s (captcha / parse miss?)", + "newbuilding fetch returned None house_id=%s url=%s — причина в строке " + "'initialState extraction failed' выше", house_id, zhk_url, ) @@ -516,15 +569,15 @@ async def backfill_newbuilding_enrichment( continue # ── Save under a SAVEPOINT so one bad house can't poison the batch ── - # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally, - # so we snapshot the row counts BEFORE and recompute the delta AFTER its commit - # rather than relying on the nested transaction staying open. - pd_before, rc_before, rv_before = _house_enrichment_counts(db, house_id) + # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally. + # COUNT(*) до сохранения нужен ТОЛЬКО для had_reliability (дедуп ниже): сколько + # записано, теперь сообщают сами писатели, а не разница COUNT'ов (#2807). + _, rc_before, _ = _house_enrichment_counts(db, house_id) try: had_reliability = rc_before > 0 # 1) price_dynamics + reliability + houses UPDATE (existing, commits inside). - save_newbuilding_enrichment(db, house_id, enrichment) + saved = save_newbuilding_enrichment(db, house_id, enrichment) # 2) reviews — added here (save_newbuilding_enrichment skips them). # SAVEPOINT around the review write so a malformed review can't lose the @@ -560,16 +613,18 @@ async def backfill_newbuilding_enrichment( sp.rollback() logger.warning("reliability dedup failed house_id=%s: %s", house_id, dexc) - pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id) - result.price_dynamics_rows += max(0, pd_after - pd_before) - result.reliability_rows += max(0, rc_after - rc_before) - result.review_rows += max(0, rv_after - rv_before) + result.price_dynamics_inserted += saved.price_inserted + result.price_dynamics_updated += saved.price_updated + result.reliability_inserted += saved.reliability_inserted + result.review_upserted += review_written result.succeeded += 1 logger.info( - "enriched house_id=%s: +pd=%d +reliability=%d +reviews=%d (parsed reviews=%d)", + "enriched house_id=%s: динамика цен +%d новых / %d обновлено, " + "reliability +%d, отзывов записано %d (распознано %d)", house_id, - max(0, pd_after - pd_before), - max(0, rc_after - rc_before), + saved.price_inserted, + saved.price_updated, + saved.reliability_inserted, review_written, len(enrichment.reviews), ) @@ -588,8 +643,8 @@ async def backfill_newbuilding_enrichment( result.duration_sec = time.time() - t0 logger.info( "newbuilding-enrich backfill done: processed=%d ok=%d skip=%d resolved=%d " - "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " - "| %.1fs", + "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / " + "%d обновлено, reliability +%d, отзывов %d | %.1fs", result.processed, result.succeeded, result.skipped_already_enriched, @@ -597,9 +652,10 @@ async def backfill_newbuilding_enrichment( result.failed_resolve, result.failed_fetch, result.failed_save, - result.price_dynamics_rows, - result.reliability_rows, - result.review_rows, + result.price_dynamics_inserted, + result.price_dynamics_updated, + result.reliability_inserted, + result.review_upserted, result.duration_sec, ) return result @@ -665,7 +721,9 @@ async def run_newbuilding_enrich( Thin scheduler wrapper around backfill_newbuilding_enrichment() — mirrors tasks/yandex_address_backfill.run_yandex_address_backfill: emit a heartbeat before the - batch, delegate to the proven backfill, then mark the scrape_run done/failed. + batch, delegate to the proven backfill, then finalise the scrape_run through the + SHARED runs.mark_backfill_finished (#2695) — ноль обогащений при ненулевых попытках + закрывается 'failed', а не 'done' (#2767; счётчики маппит to_backfill_counters). Idempotency is inherited from backfill_newbuilding_enrichment(): with force=False its SELECT excludes houses that already have BOTH price_dynamics AND reliability rows, and @@ -696,6 +754,18 @@ async def run_newbuilding_enrich( "failed_save": 0, } + def _heartbeat(progress: NewbuildingEnrichBackfillResult) -> None: + """Сигнал живости из середины цикла (#2725). Best-effort — сбой heartbeat не + должен ронять уже идущий обход.""" + try: + runs_mod.update_heartbeat(db, run_id, progress.to_dict()) + except Exception: + logger.warning( + "scheduler: newbuilding_enrich run_id=%d heartbeat failed (ignored)", + run_id, + exc_info=True, + ) + try: runs_mod.update_heartbeat(db, run_id, counters) @@ -704,14 +774,36 @@ async def run_newbuilding_enrich( limit=limit, force=force, request_delay_sec=request_delay_sec, + on_progress=_heartbeat, ) - counters = result.to_dict() - runs_mod.mark_done(db, run_id, counters) + # Честный финал через ОБЩИЙ финализатор (#2695): ноль обогащений при ненулевых + # попытках больше не 'done'. fail_hint называет ЭТАП, на котором чаще всего + # отказывало (resolve / fetch / save) — «failed=25» на этот вопрос не отвечает. + counters = result.to_backfill_counters() + stage, stage_n = max( + ( + ("resolve", result.failed_resolve), + ("fetch", result.failed_fetch), + ("save", result.failed_save), + ), + key=lambda kv: kv[1], + ) + runs_mod.mark_backfill_finished( + db, + run_id, + counters, + source="newbuilding_enrich", + fail_hint=( + f"чаще всего отказ на этапе {stage} ({stage_n} из {counters['attempted']})" + if stage_n + else None + ), + ) logger.info( - "scheduler: newbuilding_enrich run_id=%d done — processed=%d ok=%d skip=%d " - "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " - "| pending=%d %.1fs", + "scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d " + "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / " + "%d обновлено, reliability +%d, отзывов %d | pending=%d %.1fs", run_id, result.processed, result.succeeded, @@ -719,9 +811,10 @@ async def run_newbuilding_enrich( result.failed_resolve, result.failed_fetch, result.failed_save, - result.price_dynamics_rows, - result.reliability_rows, - result.review_rows, + result.price_dynamics_inserted, + result.price_dynamics_updated, + result.reliability_inserted, + result.review_upserted, result.cian_houses_pending, result.duration_sec, ) diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py new file mode 100644 index 00000000..4dcda362 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -0,0 +1,289 @@ +"""Physically delete expired personal data — ЭТАП 4 B2C retention enforcement (152-ФЗ). + +WHY: + trade_in_estimates.expires_at (и, начиная с migration 231, trade_in_leads.expires_at) + defined a retention window, but neither table had any background job that actually + DELETEd rows once expired -- expires_at was used ONLY as a read-time filter + (GET /estimate/{id}: "AND expires_at > NOW()"). Personal data (address / phone) + outlived its declared lifetime indefinitely, contradicting the retention policy + shown to the user. + +WHAT: + Batched physical DELETE for both tables, run nightly by the kit-scheduler (see + app.services.product_handlers._job_purge_expired_trade_in_data, scrape_schedules + row seeded by migration 231 -- seeded enabled=false, see that migration's docstring + for why). Same architecture as app/tasks/deactivate_stale_avito.py (sync, DB-only, + invoked via run_in_executor from the async kit handler). + + - trade_in_estimates: ON DELETE CASCADE already cleans up estimate_photos + (007_estimate_photos.sql) and avito_imv_evaluations (018_avito_imv_evaluations.sql) + for each deleted estimate. + - trade_in_leads: ON DELETE SET NULL on trade_in_leads.estimate_id (172_trade_in_leads.sql) + means a lead created from a now-purged estimate SURVIVES with estimate_id nulled -- + it has its OWN retention clock (trade_in_leads.expires_at) and its own PII (phone), + purged independently below. + +⚠️ trade_in_estimates DELETE is scoped to `created_by IS NULL` (deep-review finding, + 2026-08-06, HIGH): `expires_at` on this table is set UNCONDITIONALLY for every + estimate, B2B pilot or anonymous (`now + settings.trade_in_estimate_retention_hours`, + see app/services/estimator.py) -- it is a TTL on the ESTIMATE LINK/PDF staying + resolvable (GET /estimate/{id}: 404 past expiry, PDF export: 410 past expiry), NOT a + declared retention deadline for the ROW. B2B pilots' consent is closed by contract + (see migration 229's `consent` column asymmetry: NULL for `created_by IS NOT NULL`, + the exact same B2B-vs-B2C split used here) and their estimates are the live basis for + /trade-in/history, /team/employees/{id}/history and the cache-stats dashboards (see + app/api/v1/trade_in.py, app/api/v1/team.py) -- deleting them past a 24h *link* TTL + would be silent, irreversible data loss of pilots' own operational data, not a + privacy-driven cleanup. Audited against prod on 2026-08-06: of 1057 rows, 1040 had + already crossed `expires_at`, and 911 of THOSE belonged to named pilots + (`created_by` set: admin, kopylov, brusnika, praktika, pilottest, admintest, user1). + Without the `created_by IS NULL` guard, one unattended run at + `batch_size=500, max_batches=20` (the defaults) would have deleted essentially the + whole table. `created_by IS NULL` is the honest B2C population -- 129 rows in that + same audit. trade_in_leads has no `created_by` column (never had a B2B/B2C split -- + its own `expires_at` really is a 180-day retention deadline, not a link TTL, see + migration 231) so its DELETE below is intentionally NOT scoped the same way. + +BATCHING (не единый DELETE по всей таблице): + Each table is drained in batches of `batch_size` rows (default + settings.trade_in_purge_batch_size), each batch its OWN statement + its OWN commit + (bounds lock/transaction duration on a backlog). A run stops draining a table once a + batch returns fewer rows than batch_size (caught up) OR after `max_batches` iterations + (safety cap on total run duration -- any remaining backlog drains over subsequent + nightly runs, not one giant transaction). Idempotent: rows already deleted simply + don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier + committed batches deleted (correct, not rolled back) and mark_failed records the + partial counters reached so far. + +Payments retention (PR #2754): the `created_by IS NULL` population above is EXACTLY + the future paying-customer population -- the owner sells this report to + individuals for money, and a paid row must outlive the 24h `expires_at` link TTL + (a separate column, `retain_until`, set by the -- separate, not-yet-existing -- + payment fulfillment code to now() + settings.trade_in_paid_retention_days, NOT + a change to `expires_at` itself). Two independent safeguards were added to + `_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS payments) + plus a pre-flight count in `purge_expired_trade_in_data` that refuses to run at + all if it finds an ANOMALOUS paid candidate -- see the SQL constants and + `_preflight_paid_candidates` below for the mechanics (deep-review finding + 2026-08-06 MEDIUM on PR #2754: the pre-flight predicate itself must ALSO carry + `retain_until IS NULL`, otherwise a perfectly healthy paid row trips it and + wedges the job permanently -- see that function's docstring). No payment code + lives in this file. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.services import scrape_runs as runs_mod + +logger = logging.getLogger(__name__) + +# Safety cap on batches per table per run -- bounds a single scheduled run's total +# duration even if the backlog is much larger than batch_size * max_batches; the +# remainder simply drains on the next nightly run (idempotent, no data loss risk). +_DEFAULT_MAX_BATCHES = 20 + +# +# Payments retention (2026-08-06, PR #2754): два независимых предохранителя +# добавлены к тому же предикату ПЕРЕД тем, как платёжный код появился в +# проекте (мина уже была заряжена: без них джоба удаляла бы будущих платящих +# клиентов). Отдельная колонка retain_until (не подъём expires_at) — потому +# что expires_at глобальный TTL расчёта на ВСЕ строки (включая неоплаченные) +# и печатается в PDF/UI как «актуальность расчёта»; поднять его до года +# означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в +# документе клиента про срок актуальности цифры: +# 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная +# строка (retain_until IS NOT NULL, migration 240) не удаляется джобой +# В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем +# через год после первой продажи. `retain_until` ставится сервисным +# кодом платёжного контура (ещё не существует в этом PR) на now() + +# settings.trade_in_paid_retention_days. +# 2. `NOT EXISTS (payments)` — независимая страховка на случай, если выдача +# забыла проставить retain_until (баг/гонка/ручной INSERT): строка, +# которой коснулись деньги, переживёт джобу даже без корректного (1). +# `payments` создана migration 233 (payments_estimate_idx — дешёвый терм). +# См. также _preflight_paid_candidates ниже — та же логика ДО первого батча. +_DELETE_EXPIRED_ESTIMATES_SQL = text( + """ + DELETE FROM trade_in_estimates + WHERE id IN ( + SELECT id FROM trade_in_estimates + WHERE expires_at < NOW() + AND created_by IS NULL + AND retain_until IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payments p WHERE p.estimate_id = trade_in_estimates.id + ) + ORDER BY expires_at + LIMIT CAST(:batch_size AS int) + ) + """ +) + +# Pre-flight (см. _preflight_paid_candidates). deep-review finding 2026-08-06 +# MEDIUM (PR #2754): первая редакция считала по БАЗОВОМУ предикату БЕЗ +# retain_until вообще -- а это ловит и штатно-здоровые оплаченные строки +# (retain_until проставлен, есть payments) точно так же, как настоящую +# аномалию (retain_until НЕ проставлен, но payments есть) -- джоба вставала +# на первой же честной продаже и больше никогда не запускалась (вместе с ней +# вставало и удаление лидов, вызываемое из той же функции ПОСЛЕ этой +# проверки -- 180-дневный purge по 152-ФЗ тоже переставал бы работать). +# Правильная форма: базовый предикат AND "новый предохранитель НЕ сработал +# бы" (retain_until IS NULL) AND "признак аномалии" (payments всё же есть). +# Здоровая оплаченная строка (retain_until IS NOT NULL) исключается ЭТИМ +# термом -- она и так под DELETE не попадает (см. safeguard 1 выше), тревогу +# поднимать не должна. +_PREFLIGHT_PAID_CANDIDATES_SQL = text( + """ + SELECT count(*) FROM trade_in_estimates e + WHERE e.expires_at < NOW() + AND e.created_by IS NULL + AND e.retain_until IS NULL + AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id) + """ +) + +_DELETE_EXPIRED_LEADS_SQL = text( + """ + DELETE FROM trade_in_leads + WHERE id IN ( + SELECT id FROM trade_in_leads + WHERE expires_at < NOW() + ORDER BY expires_at + LIMIT CAST(:batch_size AS int) + ) + """ +) + + +def _drain_expired( + db: Session, + stmt: Any, + *, + batch_size: int, + max_batches: int, + label: str, + counters: dict[str, int], + counter_key: str, +) -> None: + """Run `stmt` (one bounded DELETE batch) repeatedly until caught up or capped. + + Commits after EVERY batch -- keeps each individual transaction/lock short even + when the backlog is large. Updates `counters[counter_key]` INCREMENTALLY (not + just once at the end) so that a mid-run exception on a LATER batch still leaves + an accurate count of what was actually deleted-and-committed by earlier batches + -- those rows are gone for real (commit already happened) whether or not this + function ever returns normally. + """ + for batch_num in range(1, max_batches + 1): + result = db.execute(stmt, {"batch_size": batch_size}) + deleted = result.rowcount or 0 + db.commit() + counters[counter_key] += deleted + logger.info( + "purge_expired_trade_in_data: %s batch=%d deleted=%d (running_total=%d)", + label, + batch_num, + deleted, + counters[counter_key], + ) + if deleted < batch_size: + break # caught up -- fewer expired rows left than one batch + + +def _preflight_paid_candidates(db: Session) -> int: + """Safety gate: count ANOMALOUS purge-candidates -- base predicate, retain_until + IS NULL (safeguard 1 did NOT protect the row), AND a payments row exists anyway. + + Runs BEFORE any DELETE batch. A non-zero result means fulfillment failed to set + `retain_until` on a row money actually touched (bug/race/manual INSERT) -- this + run must not delete anything; see `purge_expired_trade_in_data` below, which + aborts before the first batch when this returns non-zero. + + MUST include `retain_until IS NULL` (deep-review finding 2026-08-06 MEDIUM, PR + #2754): a healthy paid row (retain_until set, has a payments row) is the EXPECTED + steady state one day after every sale -- without this term it counts as a "paid + candidate" too, so the very first successful sale permanently wedges this job + (mark_failed, zero deletions, forever -- and since leads purge runs from the same + function AFTER this check, the unrelated 180-day lead retention would also stop). + """ + return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one() + + +def purge_expired_trade_in_data( + db: Session, + run_id: int, + *, + batch_size: int | None = None, + max_batches: int | None = None, +) -> dict[str, int]: + """Delete expired rows from trade_in_estimates + trade_in_leads, in bounded batches. + + Sync (invoked via run_in_executor from the kit-scheduler handler, same pattern as + deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed). + + Returns {"estimates_deleted": N, "leads_deleted": M}. + + Payments retention pre-flight (see `_preflight_paid_candidates`): if any + purge-candidate estimate has `retain_until IS NULL` AND a `payments` row (the + ANOMALY -- fulfillment failed to set retain_until on a row money touched), the + run aborts BEFORE the first DELETE batch (estimates OR leads) -- zero rows + deleted, `mark_failed` records why. Healthy paid rows (retain_until set) do NOT + trip this -- they never matched the check to begin with. This is deliberately + checked outside the `try` below so it can never be caught and silently + re-reported as a generic mid-run failure -- it is a distinct, actionable + pre-condition failure. + """ + batch_size = batch_size or settings.trade_in_purge_batch_size + max_batches = max_batches or _DEFAULT_MAX_BATCHES + counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0} + + paid_candidates = _preflight_paid_candidates(db) + if paid_candidates: + error = ( + f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates " + "row(s) have retain_until IS NULL but a matching payments row -- " + "refusing to run, zero rows deleted" + ) + logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error) + runs_mod.mark_failed(db, run_id, error, counters) + raise RuntimeError(error) + + try: + _drain_expired( + db, + _DELETE_EXPIRED_ESTIMATES_SQL, + batch_size=batch_size, + max_batches=max_batches, + label="trade_in_estimates", + counters=counters, + counter_key="estimates_deleted", + ) + _drain_expired( + db, + _DELETE_EXPIRED_LEADS_SQL, + batch_size=batch_size, + max_batches=max_batches, + label="trade_in_leads", + counters=counters, + counter_key="leads_deleted", + ) + runs_mod.mark_done(db, run_id, counters) + logger.info( + "purge_expired_trade_in_data run_id=%d done: estimates_deleted=%d leads_deleted=%d", + run_id, + counters["estimates_deleted"], + counters["leads_deleted"], + ) + return counters + except Exception as exc: + logger.exception("purge_expired_trade_in_data run_id=%d failed", run_id) + db.rollback() + runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) + raise diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 00e49fdc..487179d5 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -9,26 +9,47 @@ видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и -поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага +поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. +#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с +LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING +событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его +застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor) +писал ERROR с самого начала — расходилась только эта джоба. + +ВАЖНО про «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, тревога снова означает +«источник/загрузка встали», а не «мы давно не ходили». + Порог алерта (документирование выбора): 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 — лейбл - ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог: - 35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет - (иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев - → алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1. + публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл + ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток. + Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста + ~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл + поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри + рабочего диапазона — если такт загрузки будут менять, пересчитай потолок + (пол + interval_days) и сверь с 60. Задача синхронная (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 тестируется без БД). -Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING -достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего +Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи +достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего оценивать). """ @@ -136,7 +157,10 @@ def check_sber_freshness( 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: - logger.warning( + # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, + # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), + # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. + logger.error( "sber freshness: sber_price_index пуст/недоступен для region=%s " "(вторичка) — оценить свежесть нельзя", SBER_MONITOR_CITY, @@ -156,7 +180,9 @@ def check_sber_freshness( } if verdict.stale: - logger.warning( + # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) — + # 9 срабатываний на проде дали ноль событий. См. докстринг модуля. + logger.error( "sber freshness: max(period_month)=%s устарел на %d дней " "(> порога %d = sber_index_max_age_days %d + lag %d); " "СберИндекс time-adjustment ДКП-сделок мог отстать — " diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index b6b6f865..51e7412c 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -12,8 +12,18 @@ offer detail page via curl_cffi AsyncSession (chrome120 + proxy) — mirrors yandex_address_backfill.py which already gets full HTML from Yandex on prod. Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via save_detail_enrichment. Track consecutive parse→None results; abort after -max_consecutive_blocks (mark_done, not mark_failed — retry next night via -NULL detail_enriched_at). +max_consecutive_blocks. Прогон с нулём обогащений теперь 'failed', не 'done' +(#2674, runs.mark_backfill_finished): на проде 31 прогон из 52 упирался ровно в +этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток +снапшота уедет в следующую ночь через NULL detail_enriched_at. + +Почему брейкер срабатывал так часто (разобрано 2026-08-06, замеры в комментарии +у OFFER_URL_PATTERN): в очереди лежали карточки новостроек, у которых source_url +ведёт на сайт застройщика, а не на realty.yandex.ru/offer//. Парсер отвергает +такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они +пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон +целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может +разобрать, а размер отброшенного видно в counters.unenrichable_pending. Why curl_cffi and not YandexDetailScraper.fetch_detail: fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS @@ -35,16 +45,33 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings from app.services import scrape_runs as runs_mod +from app.services.proxy_egress import resolve_proxy_url logger = logging.getLogger(__name__) __all__ = [ + "OFFER_URL_PATTERN", "YandexDetailBackfillResult", "run_yandex_detail_backfill", ] +# Условие, при котором обогащение этого объявления вообще возможно (#2723-класс). +# `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` и без него +# возвращает None ЕЩЁ ДО обращения к HTML (providers/yandex/detail.py:150) — то есть +# отказ предрешён регуляркой, а не капчей. +# +# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют +# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru, +# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из +# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer//. +# +# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один +# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон: +# 32 прогона из 53 закончились ровно так — attempted=5, enriched=0, 23 секунды. +# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим. +OFFER_URL_PATTERN = "/offer/[0-9]+" + @dataclass class YandexDetailBackfillResult: @@ -53,6 +80,7 @@ class YandexDetailBackfillResult: attempted: int = 0 enriched: int = 0 failed: int = 0 + unenrichable_pending: int = 0 duration_sec: float = field(default=0.0) def to_dict(self) -> dict[str, int]: @@ -60,6 +88,7 @@ class YandexDetailBackfillResult: "attempted": self.attempted, "enriched": self.enriched, "failed": self.failed, + "unenrichable_pending": self.unenrichable_pending, "duration_sec": int(self.duration_sec), } @@ -85,7 +114,8 @@ async def run_yandex_detail_backfill( (possible captcha wall); consecutive None → abort after max_consecutive_blocks. Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> - mark_done (incl. partial / consecutive-None abort) / mark_failed (exception only). + mark_backfill_finished (done / failed при нуле обогащений, #2674); + mark_failed напрямую — только при исключении. """ batch_size = int(params.get("batch_size", 800)) budget_sec = float(params.get("budget_sec", 3600)) @@ -102,6 +132,9 @@ async def run_yandex_detail_backfill( # SNAPSHOT: single SELECT at start -- NOT re-selected in loop. # Priority: is_active DESC (active first), scraped_at DESC (newest first). + # Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает + # (см. комментарий у константы): в очередь не берём то, что заведомо + # непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером. snapshot = ( db.execute( text( @@ -111,23 +144,53 @@ async def run_yandex_detail_backfill( WHERE source = 'yandex' AND detail_enriched_at IS NULL AND source_url IS NOT NULL + AND source_url ~ CAST(:offer_url_pattern AS text) ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST LIMIT CAST(:batch_size AS int) """ ), - {"batch_size": batch_size}, + {"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN}, ) .mappings() .all() ) + # Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено + # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). + counters.unenrichable_pending = int( + db.execute( + text( + """ + SELECT count(*) + FROM listings + WHERE source = 'yandex' + AND detail_enriched_at IS NULL + AND source_url IS NOT NULL + AND source_url !~ CAST(:offer_url_pattern AS text) + """ + ), + {"offer_url_pattern": OFFER_URL_PATTERN}, + ).scalar_one() + ) + if counters.unenrichable_pending: + logger.info( + "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: " + "source_url ведёт не на карточку Яндекса (%s), парсер их отвергает " + "до сети", + run_id, + counters.unenrichable_pending, + OFFER_URL_PATTERN, + ) + if not snapshot: logger.info( "yandex_detail_backfill: run_id=%d -- no pending listings " "(detail_enriched_at IS NULL = 0), done", run_id, ) - runs_mod.mark_done(db, run_id, current_counters) + # to_dict(), а не current_counters: пустая очередь при непустом + # unenrichable_pending — самый важный случай этого счётчика. + runs_mod.mark_done(db, run_id, counters.to_dict()) return counters logger.info( @@ -140,8 +203,15 @@ async def run_yandex_detail_backfill( max_consecutive_blocks, ) - # Build proxies dict once — mirrors yandex_address_backfill.py - _proxy = settings.scraper_proxy_url + # Build proxies dict once — mirrors yandex_address_backfill.py. + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если + # пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/ + # нездоровы для yandex -- resolve_proxy_url бросает ProxyPoolExhaustedError + # (fail-closed, #2616): НАРОЧНО не ловим здесь -- штатный except Exception ниже + # (mark_failed + logger.exception + raise) уже даёт явную деградацию run'а с + # понятным логом, отдельный catch не нужен. + _proxy = resolve_proxy_url(db, "yandex") _proxies = {"http": _proxy, "https": _proxy} if _proxy else None consecutive_none = 0 @@ -278,9 +348,11 @@ async def run_yandex_detail_backfill( counters.duration_sec = time.monotonic() - start current_counters = counters.to_dict() - runs_mod.mark_done(db, run_id, current_counters) + runs_mod.mark_backfill_finished( + db, run_id, current_counters, source="yandex_detail_backfill" + ) logger.info( - "yandex_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " + "yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "failed=%d duration=%.1fs", run_id, counters.attempted, diff --git a/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql b/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql new file mode 100644 index 00000000..5144181f --- /dev/null +++ b/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql @@ -0,0 +1,204 @@ +-- 211_sales_vs_listings_segment_guard.sql +-- Purpose: #2660 — «медианный торг» на витрине /sales-vs-listings считался с участием +-- ПЕРВИЧКИ. CTE window_listings в street_sales_vs_listings() (миграция 205) читает +-- listings без сегментного гарда #1186, поэтому в пару к ДКП-сделке вторички мог +-- встать лот застройщика: девелоперский прайс (фиксированный, без торга) формирует +-- показываемый пользователю процент скидки. +-- +-- Прод-замер (2026-08-05, БД tradein): +-- - популяция кандидатов на пейринг (окно period_months+6 = 30 мес, price_rub > 0): +-- 93 241 строк, из них novostroyki 25 428 (27.3%), vtorichka 66 256, +-- listing_segment IS NULL 1 557 (legacy до м.011 — считаем вторичкой); +-- - симуляция по 20 самым «густым» (улица, комнаты) ЕКБ (2 120 сделок): +-- до гарда 1 219 сделок получили listing-match, из них 299 (24.5%) — против +-- новостройки; медианный торг −18.18%. После гарда: 994 матча, медиана −17.11% +-- (+1.07 п.п. — торг перестаёт «утяжеляться» за счёт первички). +-- +-- Что НЕ меняется и почему: +-- - `is_active` в window_listings НЕ добавляем — осознанно (см. 067/205): функция +-- намеренно смотрит и снятые объявления, иначе к сделке нечего подставить +-- (объявление снимают ПОСЛЕ продажи — активные для пейринга бесполезны). +-- - Свежесть здесь тоже не при чём по той же причине: пейринг привязан к дате +-- сделки (window_days ± grace), а не к «сейчас». +-- - Сигнатура функции НЕ меняется (те же 7 аргументов, что после м.205). +-- CREATE OR REPLACE FUNCTION с ИЗМЕНЁННЫМ списком типов создал бы ВТОРУЮ +-- перегрузку вместо замены (грабли #2627, разобраны в шапке 205) — здесь +-- список типов побайтово тот же, значит замена in-place, оверлоада не будет. +-- DROP FUNCTION намеренно НЕ делаем: 205 уже дропнула старую 6-арг сигнатуру, +-- а дропать текущую 7-арг перед CREATE — лишний оконный простой для caller'а. +-- +-- Сегментный предикат — канонический #1186 (тот же, что в _COMMON_WHERE эстиматора +-- и в 109_asking_to_sold_ratio_segment_filter.sql): +-- (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka') +-- NULL = legacy вторичка до миграции 011 (rosreestr/avito/yandex без сегмента), её +-- отбрасывать нельзя — потеряли бы 1 557 валидных кандидатов. +-- +-- ЗАВИСИМОСТИ: 205 (текущее тело + 7-арг сигнатура), listings.listing_segment (м.011). +-- Deploy order: после 210. Идемпотентность: CREATE OR REPLACE + COMMENT ON — оба +-- по конструкции re-run safe, ре-apply просто перезапишет то же тело. +-- Caller (app/api/v1/trade_in.py, /sales-vs-listings) не меняется — сигнатура и +-- RETURNS TABLE те же. + +BEGIN; + +CREATE OR REPLACE FUNCTION street_sales_vs_listings( + p_street_pattern text, + p_area_m2 numeric, + p_rooms integer, + p_window_days integer DEFAULT 180, + p_area_tolerance numeric DEFAULT 0.15, + p_period_months integer DEFAULT 24, + p_target_city text DEFAULT NULL +) +RETURNS TABLE ( + deal_id bigint, + deal_date date, + deal_price_rub bigint, + deal_price_per_m2 integer, + deal_area_m2 numeric, + deal_rooms integer, + deal_floor integer, + deal_address text, + listing_id bigint, + listing_source text, + listing_source_url text, + listing_date date, + listing_price_rub bigint, + listing_price_per_m2 integer, + listing_area_m2 numeric, + days_listing_to_deal integer, + discount_pct numeric +) +LANGUAGE sql +STABLE +AS $$ + WITH window_deals AS ( + -- Сделки в улице + период. Фильтр по rooms + area + (#2583 H4) city. + SELECT + d.id AS deal_id, + d.deal_date AS deal_date, + d.price_rub AS deal_price_rub, + d.price_per_m2 AS deal_price_per_m2, + d.area_m2 AS deal_area_m2, + d.rooms AS deal_rooms, + d.floor AS deal_floor, + d.address AS deal_address + FROM deals d + WHERE d.source = 'rosreestr' + AND d.address ILIKE p_street_pattern + AND d.rooms = p_rooms + AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND d.deal_date > NOW() - (p_period_months || ' months')::interval + AND d.price_rub > 0 + -- #2583 H4: deals.city заполнена на 100% — строгое равенство. + -- NULL p_target_city (город вне словаря) → фильтр не применяется. + AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city)) + ), + window_listings AS ( + -- Кандидаты-listings на той же улице, rooms exact, area ±tolerance, + -- (#2583 H4) тот же город что deals-сторона, (#2660) только вторичка. + SELECT + l.id AS listing_id, + l.source AS listing_source, + l.source_url AS listing_source_url, + l.listing_date AS listing_date, + l.price_rub AS listing_price_rub, + l.price_per_m2 AS listing_price_per_m2, + l.area_m2 AS listing_area_m2, + l.rooms AS listing_rooms, + COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date + FROM listings l + WHERE l.address ILIKE p_street_pattern + AND l.rooms = p_rooms + AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND l.price_rub > 0 + AND COALESCE(l.listing_date, l.scraped_at::date) + > NOW() - ((p_period_months + 6) || ' months')::interval + -- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%, + -- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим" + -- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий + -- фильтр выбросил бы почти все listings кроме avito. + AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)) + -- #2660 novostroyki guard (#1186): к ДКП-сделке вторички нельзя + -- подставлять лот застройщика — девелоперский прайс не торгуется и + -- уводит показываемый «медианный торг». Прод: 27.3% кандидатов — + -- первичка. NULL = legacy вторичка до м.011, оставляем. + AND (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka') + ), + paired AS ( + -- LEFT JOIN: сохраняем все сделки даже если нет listing match. + -- Для каждой сделки выбираем listing с listing_date ближайший + -- к deal_date (предпочтительно перед сделкой). + SELECT DISTINCT ON (wd.deal_id) + wd.deal_id, + wd.deal_date, + wd.deal_price_rub, + wd.deal_price_per_m2, + wd.deal_area_m2, + wd.deal_rooms, + wd.deal_floor, + wd.deal_address, + wl.listing_id, + wl.listing_source, + wl.listing_source_url, + wl.listing_date, + wl.listing_price_rub, + wl.listing_price_per_m2, + wl.listing_area_m2, + (wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal, + CASE + WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0 + THEN ROUND( + (wd.deal_price_rub - wl.listing_price_rub)::numeric + / wl.listing_price_rub * 100, + 2 + ) + ELSE NULL + END AS discount_pct + FROM window_deals wd + LEFT JOIN window_listings wl + ON wl.listing_event_date + BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date + AND (wd.deal_date + interval '30 days')::date + ORDER BY + wd.deal_id, + -- prefer listing event дата перед сделкой и ближе к ней + CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END, + CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END, + ABS((wd.deal_date - wl.listing_event_date)) + ) + SELECT + deal_id, + deal_date, + deal_price_rub, + deal_price_per_m2, + deal_area_m2, + deal_rooms, + deal_floor, + deal_address, + listing_id, + listing_source, + listing_source_url, + listing_date, + listing_price_rub, + listing_price_per_m2, + listing_area_m2, + days_listing_to_deal, + discount_pct + FROM paired + ORDER BY deal_date DESC; +$$; + +COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS + 'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, ' + 'city-filter #2583 H4 (миграция 205), segment-guard #2660/#1186 (миграция 211). ' + 'Per-street matching: address ILIKE, area ±tolerance, rooms exact, window_days ' + 'до даты сделки (+30д grace), city-scope (p_target_city, deals строго / listings ' + 'терпимо к NULL), listings — только вторичка (listing_segment IS NULL или ' + 'vtorichka). Возвращает LEFT JOIN — сделки без listing match имеют ' + 'listing_* = NULL. discount_pct = (deal - listing) / listing * 100. ' + 'is_active намеренно НЕ фильтруется: снятые объявления и есть материал пейринга.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql b/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql new file mode 100644 index 00000000..07aaea2e --- /dev/null +++ b/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql @@ -0,0 +1,68 @@ +-- 212_sber_index_pull_weekly.sql +-- sber_index_pull: такт 28 дней → 7. Ревью PR #2681 (#2674). +-- +-- ПОЧЕМУ. Монитор sber_freshness_monitor алертил при age > 60д +-- (sber_index_max_age_days 35 + lag_allowance 25). Прод-разбор всех 24 прогонов +-- монитора (read-only, 2026-08-06, scrape_runs.counters) показал ПИЛУ, а не застой: +-- +-- 13-16.07 alert=1 age 73,74,75,76 latest_month=5 (май) +-- 17.07 alert=0 age 46 latest_month=6 ← день загрузки +-- 18-31.07 alert=0 age 47..60 latest_month=6 +-- 01-05.08 alert=1 age 61..65 latest_month=6 +-- +-- Механика: загрузка ходила раз в 28 дней и приносила период на месяц новее, а +-- возраст считается от ПЕРВОГО ЧИСЛА покрытого месяца. Значит в момент самой +-- свежей загрузки возраст уже ~46 (07-17 минус 06-01), к следующей дорастает до +-- 46+28=74, и порог 60 лежит ВНУТРИ [46, 74] — тревога пересекала его каждый +-- цикл, 14 суток из 28. Девять срабатываний, поданных в #2674 как улика застоя +-- бенчмарка, — это замер НАШЕГО СОБСТВЕННОГО ТАКТА. После #2681 (WARNING → ERROR) +-- это стало бы ежедневным событием две недели в месяц, гаснущим само собой — +-- ровно та ложная тревога, которая приучает не читать алерты. +-- +-- ПОЧЕМУ ТАКТ, А НЕ ПОРОГ. Рассматривались два варианта: +-- (A) поднять lag_allowance 25 → 40 (порог 75 против потолка 74). Запас ОДИН +-- день: любой сдвиг окна/пропуск прогона на сутки — и ложная тревога +-- возвращается. Порог при этом продолжает кодировать наш такт, а не +-- поведение источника. Отклонено. +-- (B) ЭТА миграция: такт 28 → 7. Потолок возраста становится floor+7 ≈ 53 при +-- том же пороге 60 — запас 7 суток, т.е. один пропущенный недельный цикл +-- поглощается, два подряд дают тревогу (и это уже осмысленная тревога). +-- Порог 60 начинает означать ИМЕННО «Сбер перестал публиковать / загрузка +-- сломалась», а не «мы давно не ходили». +-- +-- ЦЕНА. pull_sber_indices делает SBER_REF_AREAS (3: 643/66/77) × SBER_DASHBOARDS +-- (3) = 9 GET-запросов к публичному неавторизованному sberindex.ru/api/sowa, без +-- пауз в цикле; прод-прогон 2026-07-17 занял 4 секунды (counters.duration_sec=4, +-- errors=0, upserted=639). Было 9 запросов / 28 дней, стало 9 / 7 дней = 36 в +-- месяц. Это тот же эндпоинт, который дёргают сами дашборды Сбера при каждом +-- открытии страницы; лимитов/бана на нём за всю историю прогонов не наблюдалось +-- (0 ошибок в 6 прогонах). Риск нагрузки считаем отсутствующим. +-- +-- ПОБОЧНО. Оценщик имеет СВОЙ per-estimate guard свежести с порогом +-- settings.sber_index_max_age_days=35. Он пробивается всегда, потому что возраст +-- стартует с ~46. Недельный такт сокращает НАШУ задержку обнаружения с ≤28 суток +-- до ≤7, то есть возраст = (лаг публикации Сбера) + ≤7 вместо + ≤28. Уйдёт ли он +-- под 35 — зависит от того, когда Сбер реально публикует месяц (по нашим данным +-- лаг публикации ≤46 и ≥31 суток, точнее по имеющимся прогонам не определить), +-- поэтому НЕ обещаем починку этого guard'а, только снятие нашей части задержки. +-- +-- next_run_at подтягиваем на ближайшее окно (05:00-06:00 UTC): без этого правка +-- default_params начнёт действовать только после уже запланированного прогона +-- 2026-08-14, а до тех пор ложная тревога продолжала бы идти каждый день. +-- LEAST() — чтобы повторное применение НИКОГДА не отодвигало прогон дальше. +-- +-- Идемпотентно: jsonb-конкатенация + LEAST, повторный прогон безопасен. +-- Кода не меняет: interval_days читается kit-планировщиком из default_params +-- (orchestration/scheduler.py::_defer_next_run_at, params.get("interval_days", 1)). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) || '{"interval_days": 7}'::jsonb, + next_run_at = LEAST( + next_run_at, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC' + ) +WHERE source = 'sber_index_pull'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql b/tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql new file mode 100644 index 00000000..510db1ba --- /dev/null +++ b/tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql @@ -0,0 +1,29 @@ +-- 213_listings_snapshots_status_vocab.sql +-- #2674 — словарь listings_snapshots.status стал трёхзначным: 'stale' ≠ 'closed'. +-- +-- ПРОБЛЕМА: комментарий колонки (016) обещал два значения — 'active' / 'closed' — +-- и при этом 'closed' не писал никто и никогда: status был константой 'active' у +-- всех 394 704 строк при 55 448 реально неактивных объявлениях. Писатель «снято» +-- появился в #2674, но одним значением обойтись нельзя: +-- - путь 404 (avito_detail_backfill) ЗНАЕТ, что объявления нет: площадка ответила; +-- - путь TTL (deactivate_stale_listings) знает только, что МЫ N суток не смотрели. +-- Замер: прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток +-- против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая «дата +-- продажи» одной датой. Продукт про цены: смешивать факт с догадкой дорого. +-- +-- ДЕЛАЕТ: только обновляет COMMENT — сама колонка `text` без CHECK, DDL не нужен. +-- CHECK намеренно НЕ добавляем: 394 704 существующие строки валидны, а жёсткий +-- словарь на историческую таблицу — деструктивный риск ради нулевой выгоды. +-- +-- Idempotent: COMMENT ON COLUMN — безусловная перезапись, безопасно повторно. +-- Apply after: 212_sber_index_pull_weekly.sql + +BEGIN; + +COMMENT ON COLUMN listings_snapshots.status IS + '''active'' = объявление видели в прогоне. ' + '''closed'' = площадка ответила 404 на поштучном обходе (жёсткий факт снятия). ' + '''stale'' = TTL-деактивация: мы N суток не смотрели (догадка, НЕ дата продажи). ' + 'NULL = неизвестно. Словарь расширен в #2674 — до него писалось только ''active''.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql new file mode 100644 index 00000000..5b8095e6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql @@ -0,0 +1,116 @@ +-- 214_drop_dead_run_metrics.sql +-- Purpose (#2674): удалить показатели, которые не могут быть ненулевыми. +-- +-- Один класс находок: колонка есть, админка её показывает, писателя нет ни одного — +-- и ноль читается как «всё чисто», а не как «мы это не считаем». Такой показатель +-- хуже отсутствующего: он создаёт ложную уверенность. Числа с прода 2026-08-06. +-- +-- 1. listings.is_outlier + v_data_quality.outliers_flagged +-- 93 408 строк listings, is_outlier = false у ВСЕХ, NULL у нуля — то есть +-- только DEFAULT из 002, ни одного UPDATE за всю историю. Писателя нет и не +-- задумывалось: «выброс» у эстиматора вычисляется Tukey-фильтром по КОНКРЕТНОЙ +-- подборке аналогов (estimator._filter_outliers) и живёт ровно один запрос — +-- один и тот же лот выброс для одной оценки и нормальный аналог для соседней. +-- Persist-флаг на объявлении такое отношение выразить не может в принципе, +-- поэтому реализовать «пометку» нечем: это остаток отменённой затеи +-- («помечено модели после aggregation», 002_core_tables.sql), а не недоделка. +-- +-- 2. scrape_runs.run_type +-- 3244 прогона, ровно одно значение 'city_sweep' — DEFAULT из 051; ни одно +-- место кода run_type не задаёт. Колонка подписывала «city_sweep» прогоны, +-- которые никаким sweep не были: proxy_healthcheck (1630 шт.), +-- deactivate_stale_*, sber_index_pull, rosreestr_dkp_import. Что именно бежало, +-- честно называет source — второй, вырожденной оси не нужно. +-- +-- 3. scrape_runs.http_requests / http_errors / returning_count / disappeared_count +-- Ноль во всех 3244 прогонах с миграции 015, писателя нет вообще. +-- HTTP-запросы не считает никто ни в одном фетчере — заполнить нечем без +-- сквозной инструментации всех путей. Ошибки и «пропало/вернулось» УЖЕ +-- считает тот, кто их знает, и кладёт в counters jsonb: errors_count у +-- pipeline, deactivated/revived у deactivate_stale_*. Отдельные колонки были +-- бы вторым, иначе определённым счётчиком того же слова. +-- +-- ⚠️ View-зависимость: v_data_quality (последний DDL — 095_dead_schema.sql) читает +-- listings.is_outlier и содержит CTE `SELECT * FROM listings`, что фиксирует +-- column-level зависимость на все колонки. Порядок тот же, что в 095: +-- DROP VIEW → DROP COLUMN → CREATE VIEW (уже без outliers_flagged). +-- +-- Dependencies: 002_core_tables.sql, 015_scrape_runs.sql, 051_scrape_runs_extend.sql, +-- 095_dead_schema.sql (последний DDL v_data_quality). +-- Идемпотентно: DROP VIEW IF EXISTS / DROP COLUMN IF EXISTS / CREATE OR REPLACE VIEW. + +BEGIN; + +-- ── 1. is_outlier: снять зависимый view, дропнуть колонку, пересоздать view ─── + +DROP VIEW IF EXISTS v_data_quality; + +ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS is_outlier; + +-- DDL идентичен 095, минус строка outliers_flagged (см. п.1 шапки). +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT * 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 cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (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, + (SELECT count(*) FROM v_price_divergence) AS price_disagreements_count; + +-- Комментарий из 095 утверждал, что view «refreshed on-demand by /api/v1/admin/ +-- data-quality endpoint». Это неправда с момента переписывания ручки: живой +-- /api/v1/admin/scraper/data-quality строит собственный запрос по listings/houses и +-- этого view не касается, читателей в коде нет ни одного (проверено #2674). PR, +-- тезис которого «ложный показатель хуже отсутствующего», не имеет права нести +-- ложное утверждение о читателе — пишем как есть. +COMMENT ON VIEW v_data_quality IS + 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): ' + '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. ' + '#2674: outliers_flagged убран — is_outlier не писал никто, «выброс» определён только ' + 'внутри одной подборки аналогов (estimator._filter_outliers), не на объявлении.'; + +-- ── 2-3. scrape_runs: вырожденный run_type + четыре счётчика без писателя ───── + +ALTER TABLE IF EXISTS scrape_runs + DROP COLUMN IF EXISTS run_type, + DROP COLUMN IF EXISTS http_requests, + DROP COLUMN IF EXISTS http_errors, + DROP COLUMN IF EXISTS returning_count, + DROP COLUMN IF EXISTS disappeared_count; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql b/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql new file mode 100644 index 00000000..03b78b11 --- /dev/null +++ b/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql @@ -0,0 +1,85 @@ +-- 215_avito_full_load_window_matches_cadence.sql +-- Окно ретроспективы avito_full_load приводится в согласие с тактом (#2674). +-- +-- КОНТЕКСТ. Каденс источника задаётся ключом default_params."interval_days" +-- (scheduler.compute_next_run_at), глубина обхода — ключом "incremental_days" +-- (run_avito_full_load: since = today - incremental_days, дальше пагинация +-- newest-first с ранней остановкой на первой странице без карточек >= since). +-- Это ДВА независимых литерала, которые обязаны совпадать — и они разъехались: +-- 129_avito_full_load_incremental_split.sql поставил incremental_days=2 при +-- тогдашнем ежедневном такте (2 >= 1 — окно перекрывало такт, дыр не было); +-- 206_scrape_schedules_cut_wasteful_load.sql перевёл источник на interval_days=7, +-- но incremental_days не тронул. +-- Итог на проде (scrape_schedules id=138, проверено read-only 2026-08-06): +-- {"interval_days": 7, "incremental_days": 2, ...} +-- Прогон видит listing_date в [D-2, D] = 3 календарных дня из 7; следующий прогон +-- начинает с [D+5, D+7]. Дни D+1..D+4 не попадают НИ в один прогон — 4 суток из 7 +-- (57%) структурно вне поля зрения источника. +-- +-- ЧИСЛА, обосновывающие новое значение (прод, tradein): +-- * Замер по ОДНОМУ прогону (run 2990, exhaustive 2026-08-02, 2317 строк +-- снапшота / 2184 с датой) — сколько лотов достаёт окно шириной W суток: +-- W=2 -> 147 W=6 -> 446 W=7 -> 1275 W=12 -> 1278 +-- Три вывода. Окно 2 теряет в 8.7 раза (147 против 1275). Шестёрка теряет +-- две трети семёрки (446 против 1275) — это обрыв, а не экономия. И 7..12 — +-- ПЛАТО: +3 лота на пять дополнительных суток окна, то есть семёрка стоит +-- на самой дешёвой точке плато, а не является компромиссом. +-- * Обрыв на 7 и плато за ним — свойство НЕ Avito, а КВАНТОВАНИЯ нашего же +-- парсера относительных дат (_parse_relative_date, providers/avito/serp.py): +-- «неделю назад» -> ровно today-7, «две недели назад» -> ровно today-14. +-- Возрасты 8..13 по этому пути недостижимы — на проде их 3 лота из 2184 +-- (это и есть плато). Значит бакет «возраст 7» — не «поднятые ровно неделю +-- назад», а ВСЁ, чему реально от 7 до 13 суток. W=6 режет не по пику +-- распределения, а по ГРАНИЦЕ КВАНТОВАНИЯ и теряет бакет целиком. +-- * Тот же бакет в общей выборке: (last_seen_at::date - listing_date) по +-- avito за 20 суток — возраст 0-2 = 594, возраст ровно 7 = 1212 из 3714 +-- датированных наблюдений (32.6%). +-- * Полосы соседних прогонов [D-7, D] и [D, D+7] смыкаются с суточным +-- перехлёстом, который покрывает дрейф расписания (замер: last_run +-- 2026-08-03 13:37 -> next_run_at 2026-08-10 14:16 = +7 суток 39 минут). +-- +-- ЦЕНА ПО ЗАПРОСАМ растёт НЕ пропорционально лотам. Стоимость бакета — +-- ceil(свежих / 50) страниц с полом в 1-2 страницы; при окне 7 на бакет +-- приходится ~15-20 свежих (1275 лотов на 77 бакетов = 7 комнатностей x 11 +-- ценовых seed-брекетов) — МЕНЬШЕ одной страницы. Большинство бакетов как +-- стояло на 1-2 страницах, так и останется, глубже пойдут только плотные. +-- Верхняя граница честная и продом уже пережитая: полный обход без отсечки +-- вообще — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990); окно 13-15 UTC +-- ограничивает только СТАРТ прогона, не длительность. +-- +-- ВОЗВРАЩАЕТЕ ЕЖЕДНЕВНЫЙ ТАКТ — ВЕРНИТЕ И ОКНО. Ни эта миграция (GREATEST +-- только расширяет), ни планировщик (расширяет до такта, не сужает) окно НЕ +-- сузят. interval_days 7 -> 1 при incremental_days = 7 даст ежедневный прогон +-- с семисуточной глубиной, то есть восьмикратный охват КАЖДЫЙ день. Такт и +-- окно менять одной правкой. +-- +-- Значение НЕ хардкодим числом 7, а выводим из фактического interval_days строки: +-- если такт когда-нибудь поменяют снова, повторный прогон файла (или ручной +-- вызов) снова сведёт окно с тактом. Ту же инвариантность страхует код — +-- scheduler._job_avito_full_load расширяет окно до такта и пишет warning. +-- GREATEST с текущим значением: миграция никогда не СУЖАЕТ окно, если оператор +-- выставил его шире такта осознанно. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), +-- 129_avito_full_load_incremental_split.sql (incremental_days), +-- 206_scrape_schedules_cut_wasteful_load.sql (interval_days=7). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет — idempotent by construction. +-- source сравнивается на ТОЧНОЕ равенство: 'avito_full_load_exhaustive' — соседняя +-- джоба, у неё incremental_days нет вовсе (полный обход) и трогать её нельзя. + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params + || jsonb_build_object( + 'incremental_days', + GREATEST( + COALESCE(CAST(default_params ->> 'interval_days' AS integer), 1), + COALESCE(CAST(default_params ->> 'incremental_days' AS integer), 1) + ) + ), + updated_at = NOW() +WHERE source = 'avito_full_load' + AND default_params ? 'incremental_days'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql b/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql new file mode 100644 index 00000000..4bacb319 --- /dev/null +++ b/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql @@ -0,0 +1,245 @@ +-- 216_dead_code_sweep.sql +-- Purpose (#2674, раздел «Мёртвый код»): развести три разных вещи, которые снаружи +-- выглядят одинаково — «написано и ни разу не сработало». +-- +-- 1. ОБОРВАННАЯ ПРОВОДКА — механизм рабочий, звать некому/нечем. Чиним подключением. +-- 2. МЁРТВОЕ — механизм невыразим, дублирует существующее или потерял смысл. Удаляем. +-- 3. ЗАДЕЛ — оставляем, но в схеме должно быть написано, чем он НЕ является сегодня. +-- +-- Все числа — с прод-БД tradein 2026-08-06, ТОЧНЫМ count(*). Первая редакция несла +-- сюда reltuples-оценки планировщика (listings «142 569» против реальных 93 408 — +-- раздув мёртвыми кортежами на 53%); в постоянном комментарии к схеме оценке не место, +-- тем более в PR, тезис которого — «каждое утверждение несёт число с прода». +-- +-- ── 1. ПОДКЛЮЧАЕМ ─────────────────────────────────────────────────────────── +-- +-- A. domrf_kapremont_load — расписание для загрузчика ДОМ.РФ. +-- Загрузчик (app/services/domrf_kapremont_loader.py) и CLI +-- (app/tasks/domrf_kapremont_load.py) написаны и покрыты тестами с #2013, но +-- Handler'а в product_handlers и строки в scrape_schedules не существовало — +-- вызвать его было НЕЧЕМ. Итог на проде: 29 978 строк staging, у ВСЕХ один и тот +-- же loaded_at = 2026-07-12 13:19:30 (ровно один ручной запуск), 24 дня без +-- обновления. Это не мёртвый код: houses.year_built/material_walls/total_floors и +-- дальше listings.year_built (когортный фильтр эстиматора) кормятся именно отсюда. +-- +-- B. external_valuations.filters_hash — бэкфилл из сырых ответов. +-- Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ на УРОВЕНЬ +-- ВЫШЕ — estimation.sale.filtersHash (ключи sale на проде: isError, filtersHash, +-- data, isFetching). Колонка была пуста 0/1658, при том что в сырых ответах хеш +-- есть у 139/139 строк cian_valuation и все 139 значений различны. Путь починен в +-- providers/cian/valuation.py; здесь достаём то, что уже лежит в raw_payload. +-- +-- ── 2. УДАЛЯЕМ ────────────────────────────────────────────────────────────── +-- +-- C. asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds (мигр. 098, #928). +-- Ноль читателей и ноль писателей в коде — грепом не находится ни одного +-- упоминания вне самой 098 и манифеста. Флага tier_aware_ratio_enabled, под +-- который таблицы задумывались, в конфиге не существует. Посчитаны один раз при +-- накатке (21 + 5 строк, computed_at 2026-06-27) и с тех пор не двигались, тогда +-- как ЖИВАЯ asking_to_sold_ratios обновляется ежедневно (computed_at 2026-08-05). +-- То есть в БД лежат коэффициенты выкупа сорокадневной давности, которые выглядят +-- как рабочая сегментация — их достаточно один раз прочитать по ошибке, чтобы +-- получить оценку по устаревшему рынку. Методика не потеряна: derivation-CTE +-- целиком сохранён в 098, восстановить = переприменить файл. +-- +-- D. listings.merged_into — 93 408 строк, NULL у всех, ноль упоминаний в коде. +-- Заведена в 028 «under dedup workflow», который так и не построили; 113 уже +-- писала прямым текстом «column is dead, no code writer». Дедуп объявлений живёт +-- в другом месте и по-другому (estimator._union_find_phys_dedup, во время оценки, +-- без записи в БД). Соседнюю listings.canonical НЕ трогаем — она вырождена (t у +-- всех 93 408), но её читает WHERE listings_search_mv (050/094), и снос колонки +-- потянул бы пересоздание matview с шестью индексами ради нулевого выигрыша. +-- +-- E. house_sources.raw_payload + GIN-индекс по нему — 49 502 строки, NULL у всех. +-- Оба писателя house_sources (matching/houses.py:556, house_dedup_merge.py:493) +-- эту колонку в INSERT не включают; читателей нет, из публичного контракта +-- market.v_house_sources (154) она намеренно исключена. GIN-индекс по колонке, +-- которая всегда NULL, — чистая стоимость на каждой вставке. +-- Соседний house_sources.ext_url тоже пуст 49 502/49 502, но он ВХОДИТ в +-- market.v_house_sources — удаление сломало бы обещание стабильности контракта. +-- Оставляем и подписываем (см. п. 3). +-- +-- F. v_data_quality.price_disagreements_count — показатель, который не может быть +-- ненулевым. Считает строки v_price_divergence («разброс цен между площадками у +-- одного объявления > 5%»), а на проде у 89 699 объявлений РОВНО ОДИН источник +-- каждое: distinct listing_id = 89 699 при 89 699 строках listing_sources. +-- v_price_divergence = 0 строк, v_cross_source_health = 0 строк. Причина не в +-- данных: боевой путь загрузки (scrapers/base.py::_link_listing_to_house) зовёт +-- upsert_listing_source('source_link') напрямую и НЕ зовёт match_or_create_listing — +-- см. NOTE на matching/listings.py:188. Пока связывание источников не подключено, +-- ноль здесь читается как «расхождений нет», хотя честно это «мы не сравниваем». +-- Тот же довод, по которому 214 убрала outliers_flagged. +-- +-- ── 3. ОСТАВЛЯЕМ И ПОДПИСЫВАЕМ ────────────────────────────────────────────── +-- +-- G. Сами v_price_divergence / v_cross_source_health не удаляем: они выразимы и +-- станут ненулевыми в тот день, когда связывание источников заработает. Но в +-- COMMENT должно быть написано, что они пусты СТРУКТУРНО, а не по счастью. +-- Туда же — house_sources.ext_url. +-- +-- ⚠️ View-зависимость (тот же порядок, что 214): v_data_quality содержит +-- `WITH active_listings AS (SELECT * FROM listings)`, что фиксирует column-level +-- зависимость на ВСЕ колонки listings. Порядок: DROP VIEW → DROP COLUMN → +-- CREATE VIEW. Последний DDL v_data_quality — 214_drop_dead_run_metrics.sql. +-- +-- Dependencies: 028_matching_tables.sql, 029_extend_matching_valuation_dynamics.sql, +-- 046_views.sql, 052_scrape_schedules.sql, 098_asking_to_sold_ratios_tiered.sql, +-- 176_domrf_kapremont.sql, 214_drop_dead_run_metrics.sql. +-- Deploy order: применять ПОСЛЕ деплоя backend-кода, регистрирующего +-- 'domrf_kapremont_load' в product_handlers.build_product_handlers() — иначе +-- kit-scheduler не найдёт Handler на первом due-run. next_run_at = завтра, так что +-- даже при обратном порядке накатки окно не наступит раньше следующих суток. +-- Идемпотентно: ON CONFLICT DO NOTHING / DROP ... IF EXISTS / CREATE OR REPLACE VIEW / +-- бэкфилл под WHERE filters_hash IS NULL. + +BEGIN; + +-- ── A. Расписание загрузчика ДОМ.РФ (оборванная проводка) ──────────────────── +-- enabled = true: внешний источник, но открытые данные без auth и без анти-бота +-- (тот же класс, что sber_index_pull / rosreestr_quarter_poll). +-- interval_days = 7: реестр капремонта не меняется ежедневно, а прогон качает два +-- zip и парсит ~30 тыс. строк. Недельный такт достаточен и не жжёт трафик впустую. +-- Ключ читает compute_next_run_at из default_params (см. 129). +-- Окно 01:00-02:00 UTC. Первая редакция ставила 03:00-04:00 — ровно туда, где сидит +-- refresh_search_matview (сверено с прод-таблицей scrape_schedules), то есть именно +-- то задание, которое и переносит year_built в поиск. Планировщик берёт случайный +-- момент внутри окна и гоняет источники ПАРАЛЛЕЛЬНО, порядок он не гарантирует +-- ничем — совпадение окон превращало «сначала загрузка, потом обновление поиска» +-- в подбрасывание монеты. Час до 02:00 разводит их при типовой длительности прогона +-- и остаётся раньше rosreestr_dkp_import (04:00-06:00) и +-- asking_to_sold_ratio_refresh (06:00-07:00). +-- ЧЕСТНАЯ ОГОВОРКА: гарантии всё равно нет — при аномально долгом прогоне (сеть +-- ДОМ.РФ, ретраи) свежий year_built доедет до поиска на цикл позже. Ни блокировок, +-- ни потери данных: следующее обновление matview его подхватит. +-- Соседи в 01:00-02:00 — listing_source_snapshot и avito_city_sweep_kamensk_uralskiy; +-- общих ресурсов нет (ДОМ.РФ ходит своим httpx, мимо прокси-пула). +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'domrf_kapremont_load', + true, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"interval_days": 7}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +-- ── B. Бэкфилл filters_hash из уже сохранённых сырых ответов ───────────────── +-- Путь тот же, что теперь читает парсер. Пустую строку не пишем (NULLIF) — «есть +-- ключ, но он пуст» и «ключа нет» должны остаться одинаково NULL, а не разойтись. +UPDATE external_valuations +SET filters_hash = NULLIF(raw_payload #>> '{estimation,sale,filtersHash}', '') +WHERE filters_hash IS NULL + AND raw_payload #>> '{estimation,sale,filtersHash}' IS NOT NULL; + +-- ── C. Тиерные коэффициенты выкупа: две таблицы без читателя и писателя ────── +DROP TABLE IF EXISTS asking_to_sold_ratios_tiered; +DROP TABLE IF EXISTS asking_to_sold_tier_bounds; + +-- ── D+E+F. Колонки без писателя + показатель, который не может быть ненулевым ─ +DROP VIEW IF EXISTS v_data_quality; + +ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS merged_into; + +DROP INDEX IF EXISTS house_sources_raw_payload_gin_idx; +ALTER TABLE IF EXISTS house_sources DROP COLUMN IF EXISTS raw_payload; + +-- DDL идентичен 214, минус строка price_disagreements_count (см. п. F шапки). +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT * 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 cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (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; + +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+ источниками»), ' + 'ноль в нём — честный ответ, а не мнимое благополучие.'; + +-- ── G. Подписи к тому, что оставлено как задел ─────────────────────────────── +COMMENT ON VIEW v_price_divergence IS + 'Объявления с разбросом цен между источниками > 5%. #2674: СЕГОДНЯ ВСЕГДА ПУСТ ' + 'и это структурно, а не случайно — боевой путь загрузки зовёт ' + 'upsert_listing_source(''source_link'') напрямую (scrapers/base.py::_link_listing_to_house) ' + 'и не зовёт match_or_create_listing, поэтому у каждого объявления ровно один ' + 'источник (89 699 строк listing_sources = 89 699 разных listing_id). View оставлен ' + 'как задел: станет осмысленным в тот день, когда связывание источников подключат ' + '(см. NOTE на matching/listings.py:188). Из v_data_quality исключён — там ноль ' + 'выглядел как результат проверки.'; + +COMMENT ON VIEW v_cross_source_health IS + 'Пер-объявленческая агрегация цен по источникам. #2674: пуст по той же причине, ' + 'что v_price_divergence — HAVING count(*) >= 2 недостижим, пока связывание ' + 'источников не подключено. Читателей в коде нет.'; + +COMMENT ON COLUMN house_sources.ext_url IS + '#2674: NULL у всех 49 502 строк — ни один из двух писателей house_sources ' + '(matching/houses.py, house_dedup_merge.py) эту колонку не заполняет. НЕ удалена ' + 'только потому, что входит в публичный контракт market.v_house_sources (мигр. 154), ' + 'где удаление колонки объявлено ломающим изменением. Соседний raw_payload из ' + 'контракта исключён и удалён этой же миграцией.'; + +COMMENT ON COLUMN houses.has_panorama IS + '#2674: заполняется из yandex_valuation (estimator._save_yandex_house_panorama). ' + 'До этой правки колонка была пуста у всех 9 366 домов, хотя парсер флаг разбирал. ' + 'Пишется ТОЛЬКО когда страница оценки подтверждённо отрисовалась (в мете есть год ' + 'или этажность) — иначе «метки нет» неотличимо от «страница не открылась», и NULL ' + 'честнее false.'; + +COMMENT ON COLUMN external_valuations.filters_hash IS + 'sha256 фильтров от самого Циана, estimation.sale.filtersHash (#2674: НЕ ' + 'estimation.sale.data.filtersHash — из-за лишнего уровня колонка была пуста 0/1658). ' + 'Отличается от cache_key: cache_key — наш хеш параметров ЗАПРОСА, filters_hash — ' + 'хеш того, во что Циан их разрешил, поэтому он способен схлопнуть варианты записи ' + 'одного адреса, которые cache_key разводит.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/217_position_in_serp_unexpressible.sql b/tradein-mvp/backend/data/sql/217_position_in_serp_unexpressible.sql new file mode 100644 index 00000000..950dfb13 --- /dev/null +++ b/tradein-mvp/backend/data/sql/217_position_in_serp_unexpressible.sql @@ -0,0 +1,62 @@ +-- 217_position_in_serp_unexpressible.sql +-- listings_snapshots.position_in_serp — диагноз «механизм невыразим», шаг 1 из 2. +-- +-- Dependencies: 016_listings_snapshots.sql (создала колонку). +-- Apply after: 216_dead_code_sweep.sql +-- Идемпотентно: только COMMENT ON COLUMN (CREATE OR REPLACE-семантика). +-- +-- ── ЧТО НАЙДЕНО ────────────────────────────────────────────────────────────── +-- upsert_listing_snapshot принимал position_in_serp, но НИ ОДИН боевой вызывающий +-- его не передавал: три call-site'а (scraper_kit/base.py:801 save_listings, +-- providers/cian/detail.py:382, app/tasks/avito_detail_backfill.py:454) плюс +-- ingest-скрипты — везде аргумент опущен. На проде колонка пуста 0 из 395 240 строк +-- (данные с 2026-05-30 по 2026-08-06). +-- +-- ── ПОЧЕМУ ЭТО НЕ «ПОДКЛЮЧИТЬ» ─────────────────────────────────────────────── +-- Соблазнительный вывод — «парсер выдачи знает индекс карточки, передайте его». +-- Он неверен: позиция есть свойство пары (объявление, конкретный прогон выдачи с +-- конкретными фильтрами), а выбранная структура этого отношения не выражает. +-- PRIMARY KEY (listing_id, snapshot_date) — максимум ОДНА строка на объявление в +-- сутки; run_id здесь обычный атрибут, да ещё и под COALESCE в ON CONFLICT. +-- Следствия на живых данных: +-- * 2026-08-05: 77 прогонов и 5392 total_seen дали 4595 строк снэпшотов; за +-- 2026-08-03 в таблице 13 разных run_id на одну дату. Четыре SERP-источника +-- (yandex/cian/avito/domclick city_sweep) в одни сутки пишут по одному ключу. +-- * Внутри одного city_sweep обход идёт по десяткам гео-якорей радиусом 1500 м с +-- перекрытием, и save_listings получает `anchor_lots` — 3 страницы ОДНОГО якоря, +-- а не общий ранжированный список. Одно объявление приезжает с разным индексом +-- от разных якорей того же прогона. +-- * Старый ON CONFLICT писал COALESCE(EXCLUDED.position_in_serp, <старое>) — в +-- строке оседал бы индекс последнего писателя дня. Это не «позиция в выдаче», а +-- произвольный представитель суток; хуже NULL, потому что читался бы как факт. +-- Чтобы позицию можно было хранить честно, нужна отдельная таблица с ключом +-- (run_id, listing_id) и сохранёнными фильтрами прогона. Такой задачи сейчас нет — +-- ни один потребитель позицию не читает (0 view/matview на проде ссылаются на +-- колонку), поэтому колонка удаляется, а не переносится. +-- +-- ── ПОЧЕМУ DROP НЕ ЗДЕСЬ ───────────────────────────────────────────────────── +-- Шаг 1 (этот файл + правка кода в том же PR): upsert_listing_snapshot перестаёт +-- упоминать колонку; колонка остаётся, комментарий объясняет почему. +-- Шаг 2 (отдельный PR, после того как образ с шагом 1 живёт на проде): +-- ALTER TABLE listings_snapshots DROP COLUMN IF EXISTS position_in_serp; +-- Порядок не косметический. deploy-tradein.yml применяет data/sql/*.sql ДО +-- перезапуска контейнеров («(3) Применяем SQL миграции — ДО app»), так что DROP в +-- одном деплое с правкой кода оставил бы окно в несколько минут, где ещё живой +-- СТАРЫЙ образ выполняет INSERT со списком колонок, включающим удалённую. Запись +-- снэпшотов fault-tolerant (SAVEPOINT + warning), поэтому упало бы тихо — ровно тот +-- класс потерь, который замечают через недели по дырке в истории цен. + +BEGIN; + +COMMENT ON COLUMN listings_snapshots.position_in_serp IS + 'МЁРТВАЯ, удаляется следующей миграцией (#2674). Пуста 0/395240 на 2026-08-06: ' + 'писатель принимал аргумент, ни один вызывающий его не передавал. Не подключена, ' + 'потому что механизм невыразим в этой таблице: позиция — свойство пары ' + '(объявление, конкретный прогон выдачи с конкретными фильтрами), а PK здесь ' + '(listing_id, snapshot_date) — одна строка на объявление в сутки, при том что в ' + 'одни сутки по этому ключу пишут до 13 прогонов и 4 разных SERP-источника, а ' + 'внутри одного прогона объявление приходит с разным индексом от перекрывающихся ' + 'гео-якорей. Честное хранение требует таблицы с ключом (run_id, listing_id) и ' + 'сохранёнными фильтрами прогона; читателей у позиции нет ни одного.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/218_scrape_runs_ban_kind.sql b/tradein-mvp/backend/data/sql/218_scrape_runs_ban_kind.sql new file mode 100644 index 00000000..689cee00 --- /dev/null +++ b/tradein-mvp/backend/data/sql/218_scrape_runs_ban_kind.sql @@ -0,0 +1,72 @@ +-- 218_scrape_runs_ban_kind.sql +-- scrape_runs.ban_kind — статус 'banned' перестаёт смешивать наш сбой с чужим (#2686). +-- +-- Dependencies: 015_scrape_runs.sql (создала таблицу и CHECK по status). +-- Apply after: 217_position_in_serp_unexpressible.sql +-- Идемпотентно: ADD COLUMN IF NOT EXISTS + DROP/ADD CONSTRAINT + UPDATE только там, +-- где ban_kind ещё NULL. +-- +-- ── ЧТО НАЙДЕНО ────────────────────────────────────────────────────────────── +-- Из 115 avito-прогонов со статусом 'banned' (замер на проде 2026-08-06): +-- 90 — «browser unavailable (proxy may be down)» — 503 НАШЕГО браузерного +-- сайдкара (05.07-03.08), площадка ни при чём; +-- 2 — прочие ошибки того же сайдкара (04.07, 15.07); +-- 10 — «Avito SERP firewall (browser-mode) … IP banned» — реальная блокировка +-- площадкой (16.06-06.08); +-- 11 — HTTP 429/403 (30.05-19.06); +-- 2 — прочее (21.06, 23.06). +-- То есть 92 из 115 (80%) прогонов, помеченных «нас забанили», — отказ нашей +-- собственной инфраструктуры. Оба исхода писали РАЗНЫЙ текст ошибки и получали +-- ОДИН статус: различитель лежал в данных и терялся ровно в момент присвоения. +-- +-- ── ЦЕНА, КОТОРАЯ УЖЕ УПЛАЧЕНА ─────────────────────────────────────────────── +-- Миграция 206 прочитала статус буквально — как «площадка распознаёт наш паттерн» — +-- и на этом основании замедлила avito_full_load_exhaustive более чем вдвое, переведя +-- на недельный такт. Основание было ложным. Возврат такта сюда НЕ входит: он вынесен +-- в #2687 на данные 9-10.08. +-- +-- ── ПОЧЕМУ КОЛОНКА, А НЕ НОВЫЙ СТАТУС ──────────────────────────────────────── +-- 1. У 'banned' есть побочная функция: в отличие от 'failed' он СОХРАНЯЕТ +-- done_buckets-чекпоинт пройденных бакетов. Она нужна обоим исходам — следующий +-- прогон не должен начинать с нуля ни при нашем отказе, ни при блокировке. +-- Оставив статус, получаем её даром. +-- 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых +-- молча даёт неверный ответ, если про него забыть: этот CHECK, IN-списки обоих +-- сторожей в orchestration/runs.py, Literal-фильтр admin API и хардкод-список +-- статусов во фронте (RunsTable.tsx). Это ровно тот класс оборванной проводки, +-- из-за которого задача и появилась. +-- 3. Прогон в обоих случаях требует одного обращения (оборвать, сохранить частичное); +-- различается только ДИАГНОЗ — метаданное, не состояние. +-- +-- В рантайме значение несётся от МЕСТА ПОРОЖДЕНИЯ отказа (тип исключения +-- AvitoSidecarUnavailableError), а не разбирается из текста ошибки постфактум. +-- Разбор текста ниже — РАЗОВАЯ ретро-классификация уже накопленной истории; для +-- новых строк этот путь не используется. + +ALTER TABLE scrape_runs + ADD COLUMN IF NOT EXISTS ban_kind text; + +COMMENT ON COLUMN scrape_runs.ban_kind IS + 'Диагноз status=''banned'' (#2686): platform — площадка заблокировала ' + '(firewall/403/captcha); infra — не отдала НАША инфраструктура (браузерный ' + 'сайдкар/прокси). NULL для прогонов с другим статусом. Пишется из типа ' + 'исключения в момент отказа, не из текста ошибки.'; + +ALTER TABLE scrape_runs DROP CONSTRAINT IF EXISTS scrape_runs_ban_kind_check; +ALTER TABLE scrape_runs + ADD CONSTRAINT scrape_runs_ban_kind_check + CHECK (ban_kind IS NULL OR ban_kind IN ('platform', 'infra')); + +-- Ретро-классификация истории (разово, только там, где ещё NULL). Порядок веток +-- важен: маркер сайдкара проверяется первым, потому что текст полного обхода +-- оборачивает его в свой префикс («avito full load aborted: avito SERP +-- browser-sidecar error (page=1): browser unavailable (proxy may be down)»). +UPDATE scrape_runs +SET ban_kind = CASE + WHEN error ILIKE '%browser-sidecar error%' + OR error ILIKE '%browser unavailable%' + OR error ILIKE '%proxy may be down%' THEN 'infra' + ELSE 'platform' + END +WHERE status = 'banned' + AND ban_kind IS NULL; diff --git a/tradein-mvp/backend/data/sql/219_deactivate_stale_health_gate.sql b/tradein-mvp/backend/data/sql/219_deactivate_stale_health_gate.sql new file mode 100644 index 00000000..4fbefecd --- /dev/null +++ b/tradein-mvp/backend/data/sql/219_deactivate_stale_health_gate.sql @@ -0,0 +1,67 @@ +-- 219_deactivate_stale_health_gate.sql +-- Пороги гейта здоровья сбора для TTL-деактивации (#2659). +-- +-- ЗАЧЕМ. TTL отвечает на вопрос «объявление сняли?», а меряет «мы его давно не +-- видели». Пока обход здоров, разница мала; когда обход лёг — разница равна всему +-- инвентарю. Прод, авито 10.07-26.07.2026: 17 суток подряд без единой собранной +-- страницы, TTL=10 снял 9 033 строки, из них 1 270 доказанно вернулись живыми, +-- как только сбор восстановился (сверка listing_source_snapshots с текущим +-- last_seen_at). Код гейта — app/tasks/deactivate_stale_avito.py. +-- +-- ПОЧЕМУ НЕ ПО СТАТУСУ ПРОГОНА. Ban-детектор эти провалы НЕ ловит: +-- yandex 18.07-30.07 — 5 прогонов в сутки, ВСЕ 'done', НОЛЬ 'banned', +-- total_seen = 0 тринадцать суток подряд; +-- domklik 20.07-30.07 — 11 суток 'done' с total_seen = 0, а 02.08 TTL=14 +-- снял 6 131 строку разом. +-- Поэтому здоровье меряется результатом (сколько строк источник реально подтвердил +-- свежими за 3 суток), а не статусом прогона. +-- +-- ОТКУДА ЧИСЛА. Ряд «подтверждений за 3 суток» по дням восстановлен из +-- listing_source_snapshots (снимок last_seen_at на каждую дату), срез совпадает +-- со срезом соответствующего UPDATE (source + segments + та же колонка свежести): +-- avito (все сегменты, last_seen_at): здоровые сутки 3542..6079, +-- провал 08.07-29.07 — 0..970 -> 1500 +-- yandex (vtorichka, last_seen_at): здоровые 897..2206, провал 0 -> 500 +-- cian (vtorichka, last_seen_at): 748..4329, провалов не было -> 500 +-- domklik (все сегменты, scraped_at): сейчас 62 за 3 суток, сбор +-- фактически стоит -> 200 +-- Каждый порог лежит между максимумом провала и минимумом здоровых суток: +-- авито 970 < 1500 < 3542 — исторический случай ловится с запасом в обе стороны. +-- +-- domklik ЗАБЛОКИРУЕТСЯ СРАЗУ, и это верный исход, а не сбой миграции: источник +-- подтверждает ~50 строк в сутки, TTL по нему уже один раз (02.08) снёс инвентарь +-- целиком. Пока сбор не восстановлен, деактивации там нечего подтверждать; протухшие +-- строки закрываются фильтром свежести на стороне чтения (#2656), а не TTL. +-- +-- Цена ошибки асимметрична: пропущенная деактивация чинится следующим прогоном, +-- ложная — только повторным сбором, которого может не быть. Пороги поэтому +-- смещены в сторону «пропустить прогон». +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), +-- 090/115/160 (сами расписания deactivate_stale_*). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет. +-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где +-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором +-- значение. Снять гейт вручную: min_confirmations = 0. + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('min_confirmations', 1500), + updated_at = NOW() +WHERE source = 'deactivate_stale_avito' + AND NOT default_params ? 'min_confirmations'; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('min_confirmations', 500), + updated_at = NOW() +WHERE source IN ('deactivate_stale_cian', 'deactivate_stale_yandex') + AND NOT default_params ? 'min_confirmations'; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('min_confirmations', 200), + updated_at = NOW() +WHERE source = 'deactivate_stale_domklik' + AND NOT default_params ? 'min_confirmations'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql b/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql new file mode 100644 index 00000000..ce7462b1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql @@ -0,0 +1,72 @@ +-- 220_listings_sale_type_domclick_dialect.sql +-- Issue #2674: listings.sale_type заполнялся тремя разными диалектами. +-- +-- ЗАМЕР ПРОДА 2026-08-06 (SELECT source, sale_type, count(*) ... GROUP BY 1,2): +-- avito : free 2976 · assignment 46 · NULL 45020 +-- cian : free 14800 · fz214 4673 · alternative 1327 · dupt 409 · +-- investment 64 · dzhsk 3 · pdkp 1 · NULL 463 +-- domklik : «Свободная продажа» 5546 · «Альтернатива» 553 · +-- «Первичная продажа» 2 · NULL 493 +-- n1 : NULL 382 · yandex: NULL 16721 (колонку не пишут вовсе) +-- Итого 6101 русская строка против 24 299 английских. +-- +-- ЧТО ЭТО ЛОМАЕТ: `WHERE sale_type = 'free'` не видит ни одной строки Домклика, +-- `GROUP BY sale_type` разносит одну и ту же «свободную продажу» на две корзины. +-- +-- ЧЕГО ЭТО НЕ ЛОМАЕТ СЕЙЧАС: читателей у колонки НОЛЬ. Grep по всему репозиторию +-- (2026-08-06) даёт только писателей (providers/{avito,cian,domclick}, base.py, +-- scripts/ingest_domclick_jsonl.py) и DDL — ни одного фильтра, агрегата, поля +-- схемы API или элемента фронтенда. Поэтому здесь НЕ вводится «канон» целиком: +-- выбирать между cian-токеном `dupt` и avito-токеном `assignment` (409 vs 46 +-- строк, оба = переуступка права требования) без единого читателя — монетка. +-- Чинится ровно то, что сломано объективно: один писатель клал сырую русскую +-- фразу там, где два других уже кладут английский токен. Остаток словарей +-- (dupt/assignment, первично-договорный хвост cian fz214/dzhsk/pdkp/investment) +-- задокументирован в COMMENT и ждёт первого настоящего читателя. +-- +-- ПРОВЕРКА СМЫСЛА (перед маппингом, требование #2674): это одна и та же ось — +-- структура сделки. cross-tab sale_type × listing_segment на проде: +-- domklik «Свободная продажа»/«Альтернатива» — 100% vtorichka; +-- cian free/alternative — 8545/1185 vtorichka (те же два полюса); +-- cian fz214/dupt/investment/dzhsk/pdkp — 5089 из 5107 строк novostroyki, +-- т.е. это ПОДмножество той же оси (какой договор на первичке), а не другой +-- признак. Конфликта смыслов нет → склейка free/alternative безопасна. +-- +-- BACKFILL (счётчики сняты на проде ДО применения, 2026-08-06): +-- «Свободная продажа» → free : 5546 строк +-- «Альтернатива» → alternative : 553 строки +-- ИТОГО ожидаемо тронуто: 6099 строк. +-- «Первичная продажа» (2 строки) НЕ трогаем: у cian первичка кодируется типом +-- ДОГОВОРА (fz214/dzhsk/pdkp/dupt), маркетинговая метка Домклика договор не +-- называет — угадывать нельзя. Строки остаются как есть, видны в COMMENT. +-- +-- Писатель починен в том же PR: providers/domclick/detail.py canon_sale_type() +-- на границе save_detail_enrichment (её проходят все три производителя строк — +-- парсер карточки, ingest_domclick_jsonl.py, domclick_local_runner.py). +-- +-- Idempotent: повторный прогон — 0 строк (русских значений уже не осталось). + +BEGIN; + +UPDATE listings + SET sale_type = 'free' + WHERE source = 'domklik' + AND sale_type = 'Свободная продажа'; + +UPDATE listings + SET sale_type = 'alternative' + WHERE source = 'domklik' + AND sale_type = 'Альтернатива'; + +COMMENT ON COLUMN listings.sale_type IS + 'Структура сделки. Единого enum НЕТ — источники пишут свои словари: ' + 'cian — free/alternative/dupt/fz214/investment/dzhsk/pdkp (тип договора, ' + 'хвост fz214/dzhsk/pdkp/investment/dupt почти целиком novostroyki); ' + 'avito — free/alternative/auction/assignment (assignment = cian dupt, ' + 'токены НЕ сведены: #2674 не выбирал победителя, читателей у колонки нет); ' + 'domklik — free/alternative после миграции 220 (до неё русские фразы), плюс ' + '2 исторические строки «Первичная продажа» без соответствия в чужих словарях; ' + 'yandex и n1 колонку не пишут вовсе (100% NULL). Перед любым фильтром по ' + 'этой колонке сверьтесь с фактическим GROUP BY source, sale_type.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/221_backfill_house_suggestions_image_link.sql b/tradein-mvp/backend/data/sql/221_backfill_house_suggestions_image_link.sql new file mode 100644 index 00000000..698b7114 --- /dev/null +++ b/tradein-mvp/backend/data/sql/221_backfill_house_suggestions_image_link.sql @@ -0,0 +1,44 @@ +-- 221_backfill_house_suggestions_image_link.sql +-- Purpose (#2674, класс «площадка прислала, парсер разобрал, до базы не доехало»): +-- вернуть фотографии подсказок Avito IMV задним числом. +-- +-- Контекст. Колонка house_suggestions.image_link существует с миграции 064; до +-- PR #2682 (2026-08-06) парсер выбрасывал suggestions.items[].imageLink, а INSERT +-- её не перечислял. Правка #2682 чинит только БУДУЩИЕ строки. На проде на момент +-- этой миграции: 25 055 строк house_suggestions, image_link заполнен у 0 (count +-- точный, не reltuples). +-- +-- Почему исторические ВОССТАНОВИМЫ. Полный ответ /web/1/realty-imv/get-data +-- сохраняется целиком в house_imv_evaluations.raw_response (jsonb) — 2 685 строк, +-- у ВСЕХ 2 685 присутствует suggestions.items[0].imageLink. Ключ подсказки +-- (house_id, ext_item_id) однозначно соответствует (e.house_id, item->>'id'). +-- +-- Прод-замер ДО (SELECT-прогон этого же JOIN, 2026-08-06): +-- всего 25 055 · восстановимо 21 480 (85.7%) · не восстановимо 3 575 (14.3%). +-- 3 575 — это подсказки, чей house_id переоценивался позже: house_imv_evaluations +-- имеет UNIQUE(house_id) и ON CONFLICT DO UPDATE перетирает raw_response свежим +-- ответом, поэтому старый набор подсказок остаётся без своего сырья. Эти строки +-- невосстановимы в принципе — не «недоделанный бэкфилл», а утраченный источник. +-- +-- Идемпотентно: WHERE hs.image_link IS NULL — повторный прогон не трогает уже +-- заполненные строки и не перетирает то, что напишет живой скрейпер. +-- +-- Apply after: 216_dead_code_sweep.sql + +BEGIN; + +UPDATE house_suggestions hs +SET image_link = j.item ->> 'imageLink' +FROM house_imv_evaluations e +CROSS JOIN LATERAL jsonb_array_elements(e.raw_response -> 'suggestions' -> 'items') AS j(item) +WHERE e.house_id = hs.house_id + AND j.item ->> 'id' = hs.ext_item_id + AND hs.image_link IS NULL + AND nullif(j.item ->> 'imageLink', '') IS NOT NULL; + +COMMENT ON COLUMN house_suggestions.image_link IS + 'Ссылка на фото лота (suggestions.items[].imageLink). Пишется скрейпером с #2682; ' + 'исторические строки восстановлены из house_imv_evaluations.raw_response миграцией 221 ' + '(21 480 из 25 055; остальные потеряли сырьё при перезаписи оценки дома).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql new file mode 100644 index 00000000..35bfb069 --- /dev/null +++ b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql @@ -0,0 +1,140 @@ +-- 222_db_audit_cleanup.sql +-- Уборка по итогам ручного аудита схемы tradein (2026-08-06). Три независимых +-- части, порядок между ними не важен (разные объекты, нет пересекающихся +-- зависимостей). Идемпотентно целиком: IF EXISTS везде, CREATE OR REPLACE VIEW. +-- +-- ── A. Временные таблицы разовой чистки 02.07 ───────────────────────────────── +-- tmp_purged_junk_houses_0702 / tmp_purged_junk_links_0702 — снэпшоты записей, +-- вычищенных вручную 2026-07-02. Проверено на проде перед этой миграцией: +-- * обе существуют под этими именами, суммарно 2.9 МБ +-- (tmp_purged_junk_houses_0702 = 1608 kB, tmp_purged_junk_links_0702 = 1272 kB); +-- * pg_constraint: ни один FK НЕ ссылается на них (confrelid пусто); +-- * pg_depend: ни view, ни другой объект их не использует (только сами по себе). +-- Дальше не нужны — были just-in-case снэпшотом на случай отката чистки, месяц +-- прошёл без претензий. +-- +-- ── B. Пять строгих дублей индексов ─────────────────────────────────────────── +-- Строгий дубль = тот же access method + тот же УПОРЯДОЧЕННЫЙ список колонок +-- (включая ASC/DESC/NULLS) + тот же частичный предикат (или оба NULL) + тот же +-- opclass, независимо от UNIQUE-флага и имени. Проверено запросом по +-- pg_index/pg_stat_user_indexes/pg_opclass на проде — найдено РОВНО 5 пар (не 6, +-- см. примечание ниже), в каждой паре оставляем индекс, несущий UNIQUE-constraint +-- (дропнуть его нельзя без дропа constraint'а), дропаем чистый btree-дубль: +-- +-- agents: DROP agents_source_ext_idx +-- (дубль agents_ext_source_ext_agent_id_key, btree (ext_source, ext_agent_id); +-- 2 скана за всё время — планировщик и так предпочитал unique-версию) +-- ekb_geoportal_buildings: DROP ix_ekb_geoportal_buildings_street_house +-- (дубль ekb_geoportal_buildings_street_norm_house_norm_key, +-- btree (street_norm, house_norm); 13 945 сканов, но unique-версия того же +-- определения покрывает те же запросы — 70 702 скана на ней) +-- house_placement_history: DROP hph_source_item_idx +-- (дубль house_placement_history_source_ext_item_id_key, +-- btree (source, ext_item_id); 0 сканов — полностью мёртв) +-- house_reviews: DROP hr_source_ext_idx +-- (дубль house_reviews_source_ext_review_id_key, btree (source, ext_review_id); +-- 15 сканов) +-- sellers: DROP sellers_source_idx +-- (дубль sellers_source_ext_seller_id_key, btree (source, ext_seller_id); +-- 2 скана) +-- +-- ПРИМЕЧАНИЕ (расхождение с ожиданием «шесть»): при систематической проверке +-- (3 независимых метода: нормализованный DDL-текст, сравнение indkey/indoption, +-- сравнение opclass) строгих дублей найдено 5, не 6. Два похожих кандидата +-- ЦЕЛЕНАПРАВЛЕННО исключены — их «дубль» только по списку колонок, а порядок +-- сортировки различается (ASC,ASC у unique-версии против ASC,DESC у второй), +-- то есть это ТОТ ЖЕ класс исключения, что explicitly подтверждённые +-- idx_lss_source_date/listings_snapshots_listing_date_idx (#2607, см. миграцию 225): +-- * offer_price_history: oph_listing_time_idx (listing_id ASC, change_time DESC) +-- против offer_price_history_listing_change_uq (listing_id ASC, change_time ASC) +-- * houses_price_dynamics: hpd_house_dim_idx (..., month_date DESC) +-- против houses_price_dynamics_dim_key (..., month_date ASC) +-- Оба НЕ тронуты. Если «шесть» подразумевали один из них — нужно явное +-- подтверждение, что смешанный порядок сортировки в конкретном запросе не +-- используется (тем же способом, каким для idx_lss_source_date подтверждено +-- обратное). +-- +-- ── C. v_data_quality — явный список колонок вместо SELECT * ───────────────── +-- Сейчас: `WITH active_listings AS (SELECT * FROM listings WHERE is_active = true)`. +-- Postgres разворачивает `*` в CREATE VIEW time в полный список колонок listings +-- (89 на момент миграции) и фиксирует pg_depend на КАЖДУЮ из них — это то самое +-- уже задокументированное в 214/216 предупреждение («View-зависимость: v_data_quality +-- содержит SELECT * FROM listings, что фиксирует column-level зависимость на ВСЕ +-- колонки»), которое обязывало делать DROP VIEW → DROP COLUMN → CREATE VIEW при +-- каждой чистке listings. +-- Фактически используются только 6 колонок active_listings ниже по телу view: +-- id — IN (SELECT active_listings.id FROM active_listings) +-- lat — pct_geocoded +-- cadastral_number — pct_cadastr +-- description — pct_description +-- house_id_fk — JOIN houses h ON h.id = l.house_id_fk (pct_year_built) +-- is_active — WHERE-фильтр самого CTE (создаёт зависимость даже не будучи +-- в SELECT-списке, поэтому перечислен явно для наглядности) +-- Поведение view НЕ меняется — только явный список вместо *. Тело SELECT (16 +-- выходных колонок) скопировано без изменений из 216_dead_code_sweep.sql. +-- COMMENT ON VIEW сохраняется автоматически (CREATE OR REPLACE VIEW не сбрасывает +-- комментарий). +-- +-- Dependencies: 002_core_tables.sql (listings), 214_drop_dead_run_metrics.sql, +-- 216_dead_code_sweep.sql (последний DDL v_data_quality). +-- Идемпотентно: DROP TABLE IF EXISTS / DROP INDEX IF EXISTS / CREATE OR REPLACE VIEW. + +BEGIN; + +-- ── A ────────────────────────────────────────────────────────────────────── +DROP TABLE IF EXISTS tmp_purged_junk_houses_0702, tmp_purged_junk_links_0702; + +-- ── B ────────────────────────────────────────────────────────────────────── +DROP INDEX IF EXISTS agents_source_ext_idx; +DROP INDEX IF EXISTS ix_ekb_geoportal_buildings_street_house; +DROP INDEX IF EXISTS hph_source_item_idx; +DROP INDEX IF EXISTS hr_source_ext_idx; +DROP INDEX IF EXISTS sellers_source_idx; + +-- ── C ────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT id, lat, cadastral_number, 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 cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (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; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/223_scrape_runs_time_columns_meaning.sql b/tradein-mvp/backend/data/sql/223_scrape_runs_time_columns_meaning.sql new file mode 100644 index 00000000..b1759289 --- /dev/null +++ b/tradein-mvp/backend/data/sql/223_scrape_runs_time_columns_meaning.sql @@ -0,0 +1,71 @@ +-- 223_scrape_runs_time_columns_meaning.sql +-- Purpose (#2702): зафиксировать в схеме, что отметки времени прогона до этой +-- правки не охватывали его работу, и куда смотреть аналитике вместо разности. +-- +-- Dependencies: 015_scrape_runs.sql (создала started_at/finished_at/heartbeat_at), +-- 051_scrape_runs_extend.sql (finished_at/counters). +-- Apply after: 221_backfill_house_suggestions_image_link.sql +-- Идемпотентно: только COMMENT ON COLUMN (перезаписывает сам себя), данных не трогает. +-- +-- ── ЧТО БЫЛО СЛОМАНО ───────────────────────────────────────────────────────── +-- Финализаторы (mark_done / mark_failed / mark_banned) писали finished_at и +-- heartbeat_at через now(). В PostgreSQL now() == transaction_timestamp(): он +-- замерзает на СТАРТЕ транзакции. Финализатор выполняется той же сессией, что и +-- работа задачи; если рабочая транзакция всё это время оставалась открытой (задаче +-- нечего было коммитить — читающий батч, ноль сохранений, сохранение чужой сессией), +-- UPDATE финализатора попадал ВНУТРЬ неё и получал время НАЧАЛА работы. +-- +-- Прод-замер 2026-08-06 (487 прогонов, у которых есть и finished_at, и счётчик +-- counters.duration_sec): +-- 153 — заявленная длительность больше окна finished_at − started_at в >1.5 раза; +-- 133 — окно меньше секунды при работе дольше 10 с. +-- 126 из этих 133 окон лежат в 9-64 мс: это не разброс, а подпись механизма — +-- столько проходит от коммита claim'а до первого запроса рабочей транзакции. +-- Крайние: прогон 346 (cian_history_backfill) — 18 230 с работы при окне 32 мс; +-- 497 (newbuilding_enrich) — 6 124 с при 21 мс; 341 (yandex_address_backfill) — +-- 1 460 с при 19 мс. +-- +-- Дефект был не сплошной: он зависел от того, коммитила ли задача перед финалом. +-- Средние окно/duration_sec по источникам на том же замере: +-- cian_history_backfill 2554 / 4222 ← окно короче работы +-- newbuilding_enrich 1302 / 2078 ← короче +-- yandex_address_backfill 107 / 1022 ← короче в 10 раз +-- avito_detail_backfill 2829 / 2016 ← длиннее (норма) +-- house_imv_backfill 1173 / 1173 ← совпадает +-- cadastral_geo_match 11 / 10 ← совпадает +-- +-- ── ПОЧЕМУ ИСТОРИЮ НЕ ЧИНИМ ────────────────────────────────────────────────── +-- Восстановить настоящий finished_at по строке нельзя: реальное время конца нигде +-- не сохранилось. Но counters.duration_sec измерялся монотонными часами процесса +-- (time.monotonic / time.time в самих задачах) и транзакцией не затронут — он у +-- этих строк верный. Поэтому история не переписывается, а помечается: аналитика +-- обязана брать длительность из счётчика, а не из разности отметок. +-- +-- Правка кода (clock_timestamp() вместо now() во всех финализаторах и в heartbeat) +-- живёт в app/services/scrape_runs.py + packages/scraper-kit/.../orchestration/runs.py. + +COMMENT ON COLUMN scrape_runs.started_at IS + 'Момент claim''а прогона. Пишется create_run своей транзакцией (commit сразу ' + 'после INSERT), поэтому откат рабочей транзакции задачи его не затрагивает. ' + 'С #2702 — clock_timestamp(); до него now() (= старт транзакции тика планировщика), ' + 'что давало сдвиг в пределах тика.'; + +COMMENT ON COLUMN scrape_runs.finished_at IS + 'Момент финализации прогона. ВНИМАНИЕ: у строк ДО #2702 (2026-08-06) значение ' + 'недостоверно — писалось now() (= transaction_timestamp) внутри рабочей транзакции ' + 'задачи, поэтому у прогонов, ничего не коммитивших по ходу работы, равно времени ' + 'её НАЧАЛА. На проде так вышло у 133 из 487 прогонов со счётчиком длительности ' + '(окно < 1 с при работе > 10 с). Длительность таких прогонов брать из ' + 'counters->>''duration_sec'' (монотонные часы процесса, транзакцией не затронуты), ' + 'а НЕ из finished_at − started_at.'; + +COMMENT ON COLUMN scrape_runs.heartbeat_at IS + 'Последний признак жизни прогона; на нём стоит поиск зависших (reap_zombies, порог ' + '6 ч). У строк ДО #2702 отставал от реальности на возраст открытой рабочей ' + 'транзакции по той же причине, что finished_at, — то есть критерий «завис» решал ' + 'по замороженной отметке. С #2702 пишется clock_timestamp().'; + +COMMENT ON COLUMN scrape_runs.counters IS + 'Счётчики прогона (jsonb). Ключ duration_sec, где он есть, измерен монотонными ' + 'часами процесса и остаётся единственным достоверным источником длительности для ' + 'строк до #2702 (см. комментарий к finished_at).'; diff --git a/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql b/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql new file mode 100644 index 00000000..b8b58859 --- /dev/null +++ b/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql @@ -0,0 +1,83 @@ +-- 224_houses_house_type_canon.sql +-- Issue #2678 (хвост #2675/#2674): twin миграции 141 для таблицы ДОМОВ. +-- +-- Миграция 141 привела camelCase-вокабуляр Циана к канону только в listings. +-- В houses он остался — и каждый читатель типа дома чинил его у себя (#2675 +-- починил домовую оценку; поштучный путь и подбор аналогов продолжали сравнивать +-- 'monolithBrick' с 'monolith_brick' и не совпадать). +-- +-- ЗАМЕР ПРОДА 2026-08-06 (SELECT source, house_type, count(*) FROM houses GROUP BY 1,2): +-- канон: brick 388+5+4+1 · panel 325+3+2+1 · monolith 118+37+6+1 · +-- block 90+1+1 · monolith_brick 2+1 · wood 2 +-- camelCase: monolithBrick 48 (derived) + 8 (cian_newbuilding) = 56 · +-- gasSilicateBlock 1 · aerocreteBlock 1 +-- прочее: stalin 3 · other 18 · wireframe 1 +-- NULL: 8440 из 8880 строк (тип дома вообще неизвестен — не наш случай) +-- +-- ЖИВОГО ПИСАТЕЛЯ camelCase В houses НЕТ: у всех 80 неканоничных строк +-- last_scraped_at = 2026-05-24 14:04:20.012209 — одна и та же метка, т.е. +-- единственный прогон backfill'а 063 (промоут типов из listings ДО миграции 141). +-- Единственный живой писатель houses.house_type — avito-каталог домов +-- (providers/avito/houses.py), он пишет русские подписи через свою карту; в том +-- же PR он переведён на общий normalize_house_type, чтобы неизвестное значение +-- шло как NULL, а не как 'other' (его единственный источник неканона). +-- +-- ПРОВЕРКА СМЫСЛА ПЕРЕД СКЛЕЙКОЙ (требование #2674 — не слепить разное): +-- контрольная группа в своих же данных. Для каждой неканоничной строки взяты +-- типы её ЖЕ объявлений (listings.house_id_fk), уже нормализованных 141: +-- monolithBrick 56 домов — monolith_brick присутствует у ВСЕХ 56 → одно и то же +-- stalin 3 дома — brick (совпадает с решением 141: «сталинка» = кирпич) +-- aerocreteBlock 1 дом — block +-- gasSilicateBlock 1 дом — block +-- other 18 домов — разброс monolith/monolith_brick/brick/block, т.е. +-- 'other' = «неизвестно», а не отдельный материал +-- wireframe 1 дом — wireframe и у объявлений (само-согласовано) +-- Вывод: склейка безопасна ТОЛЬКО для четырёх camelCase-токенов + stalin. +-- +-- ЧТО НАМЕРЕННО НЕ ТРОГАЕМ: +-- 'other' (18) и 'wireframe' (1) — честного соответствия в каноне нет +-- (см. #2675: normalize_house_type схлопывает их в None на чтении, и это +-- правильный ответ — NULL нейтрален для soft-penalty эстиматора, а выдуманный +-- материал был бы враньём). Стирать их здесь тоже не будем: это единственный +-- след того, что источник что-то про дом сказал. +-- +-- BACKFILL (счётчики сняты на проде ДО применения, 2026-08-06): +-- monolithBrick -> monolith_brick : 56 строк +-- stalin -> brick : 3 строки +-- aerocreteBlock -> block : 1 строка +-- gasSilicateBlock -> block : 1 строка +-- foamConcreteBlock-> block : 0 строк (в houses не встречается, +-- оставлен для паритета с картой 141) +-- ИТОГО ожидаемо тронуто: 61 строка. +-- +-- Idempotent: WHERE перечисляет только мапимые токены → повторный прогон 0 строк. +-- Маппинг тождественен house_type_normalizer._RAW_TO_CANON и миграции 141 — +-- третьего словаря не заводим. + +BEGIN; + +UPDATE houses + SET house_type = CASE house_type + WHEN 'monolithBrick' THEN 'monolith_brick' + WHEN 'gasSilicateBlock' THEN 'block' + WHEN 'aerocreteBlock' THEN 'block' + WHEN 'foamConcreteBlock' THEN 'block' + WHEN 'stalin' THEN 'brick' + ELSE house_type + END + WHERE house_type IN ( + 'monolithBrick', 'gasSilicateBlock', 'aerocreteBlock', + 'foamConcreteBlock', 'stalin' + ); + +COMMENT ON COLUMN houses.house_type IS + 'Материал/тип дома, канон: panel/brick/monolith/monolith_brick/block/wood ' + '(тот же enum, что listings.house_type и scraper_kit.house_type_normalizer). ' + 'Писать сюда только через normalize_house_type — сырые вокабуляры источников ' + '(cian camelCase monolithBrick/gasSilicateBlock/stalin, yandex SCREAMING ' + 'MONOLIT_BRICK, русские подписи Авито) приводятся ДО записи, миграция 224 ' + 'вычистила исторические. Вне канона осталось намеренно: other (источник сказал ' + '«другое») и wireframe (каркас — материала в каноне нет). Неизвестный тип = ' + 'NULL, а не панель и не other: NULL нейтрален для soft-penalty эстиматора.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql new file mode 100644 index 00000000..e783502e --- /dev/null +++ b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql @@ -0,0 +1,42 @@ +-- 225_listing_source_snapshots_run_id_idx.sql +-- Индекс под внешний ключ listing_source_snapshots.run_id → scrape_runs(id) +-- ON DELETE SET NULL. Индекса на run_id нет (проверено \d listing_source_snapshots +-- на проде): есть только listing_source_snapshots_pkey (listing_source_id, +-- snapshot_date), idx_lss_source_date (listing_source_id, snapshot_date DESC), +-- idx_lss_snapshot_date (snapshot_date DESC) — ни один не начинается с run_id. +-- Таблица — 2 872 080 строк (reltuples), 696 MB (pg_total_relation_size). +-- Без индекса каждый DELETE из scrape_runs делает Seq Scan по 2.87М строк, чтобы +-- обнулить run_id у зависимых снэпшотов (ON DELETE SET NULL). +-- +-- ── Почему CONCURRENTLY и почему в файле нет BEGIN/COMMIT ──────────────────── +-- CREATE INDEX CONCURRENTLY не может выполняться внутри блока транзакции +-- (Postgres: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block"). +-- На 2.87М строк / 696 MB обычный CREATE INDEX держит ACCESS EXCLUSIVE lock на +-- время сборки (секунды-десятки секунд под нагрузкой) — на боевой таблице, +-- которую пишет активный скрейпинг, это неприемлемо; нужен CONCURRENTLY. +-- +-- Более ранние миграции с похожей потребностью (117, 120, 134, 137) сознательно +-- ОТКАЗАЛИСЬ от CONCURRENTLY с комментарием «deploy migration runner wraps each +-- file in an explicit transaction (BEGIN/COMMIT)». Перепроверено перед этой +-- миграцией: .forgejo/workflows/deploy-tradein.yml, шаг применения миграций +-- (`for sql_file in ...; psql -v ON_ERROR_STOP=on < "$sql_file"`) НЕ добавляет +-- собственный BEGIN/COMMIT и не передаёт `-1`/`--single-transaction` — транзакция +-- в тех файлах возникала ТОЛЬКО из-за их же собственных BEGIN;...COMMIT; внутри +-- файла, не из-за механизма деплоя. Эмпирическое подтверждение: в data/sql уже +-- есть применённые на проде миграции без BEGIN/COMMIT вовсе (003_seed_deals.sql, +-- 005_geocode_tracking.sql, 218_scrape_runs_ban_kind.sql, +-- 223_scrape_runs_time_columns_meaning.sql) — psql выполняет их операторы с +-- autocommit по одному, деплой не падает. Поэтому здесь BEGIN/COMMIT сознательно +-- ОПУЩЕН: файл — это один самостоятельный CREATE INDEX CONCURRENTLY, выполняемый +-- psql в autocommit-режиме. +-- +-- Идемпотентно: IF NOT EXISTS. (Единственный неидемпотентный случай — если +-- предыдущая попытка CONCURRENTLY была прервана и оставила INVALID индекс с тем +-- же именем; тогда IF NOT EXISTS молча НЕ пересоздаст его валидным, и потребуется +-- ручной `DROP INDEX CONCURRENTLY idx_lss_run_id;` перед повтором — это штатное +-- поведение CONCURRENTLY, не специфика этого файла.) +-- Dependencies: 079_listing_source_history.sql (создала таблицу и оба FK). +-- Deploy order: standalone, независим от 222_db_audit_cleanup.sql. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lss_run_id + ON listing_source_snapshots (run_id); diff --git a/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql b/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql new file mode 100644 index 00000000..8f9e2a8a --- /dev/null +++ b/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql @@ -0,0 +1,41 @@ +-- 227_drop_position_in_serp.sql +-- listings_snapshots.position_in_serp — DROP, шаг 2 из 2 (#2697, продолжение #2674). +-- +-- Dependencies: 217_position_in_serp_unexpressible.sql (диагноз + COMMENT на колонке). +-- Apply after: 224_houses_house_type_canon.sql +-- Идемпотентно: DROP COLUMN IF EXISTS. +-- +-- ── ПОЧЕМУ УДАЛЯЕМ, А НЕ ПОДКЛЮЧАЕМ ────────────────────────────────────────── +-- Полный разбор — в 217. Кратко: позиция есть свойство пары (объявление, конкретный +-- прогон выдачи с конкретными фильтрами), а PRIMARY KEY (listing_id, snapshot_date) +-- держит одну строку на объявление в сутки — при том что за 2026-08-03 по этому ключу +-- писали 13 разных run_id и четыре SERP-источника, а внутри одного city_sweep +-- объявление приезжает с разным индексом от перекрывающихся гео-якорей. Значение +-- оседало бы от последнего писателя дня и читалось бы как факт. Честное хранение — +-- отдельная таблица с ключом (run_id, listing_id) и сохранёнными фильтрами прогона, +-- то есть НЕ возврат этой колонки. +-- +-- ── ПРЕДУСЛОВИЕ ПРОВЕРЕНО ПЕРЕД МЕРЖЕМ (2026-08-06) ────────────────────────── +-- Окно «SQL применяется ДО перезапуска контейнеров» закрыто тем, что правка кода +-- (#2694) уже живёт на проде — проверено ПО КОДУ В КОНТЕЙНЕРАХ, не по зелёному +-- деплою: grep по /app в tradein-scraper и tradein-backend находит имя колонки +-- ровно в четырёх строках docstring'а snapshot_writer.py (18/34/37/76) и ни в одном +-- SQL; inspect.signature(upsert_listing_snapshot) колонку не содержит. +-- Оба живых писателя перечисляют колонки явно и этой в списке не имеют: +-- * scraper_kit/snapshot_writer.py::upsert_listing_snapshot (весь скрейп-путь), +-- * app/tasks/deactivate_stale_avito.py::_STALE_SNAPSHOT_TAIL (TTL-снимки 'stale'). +-- +-- ── ЧИТАТЕЛЕЙ НЕТ ──────────────────────────────────────────────────────────── +-- На проде: 0 непустых значений из 397 217 строк; 0 view/matview зависят от колонки +-- (pg_depend → pg_rewrite); индексов и триггеров на ней нет; foreign table над +-- listings_snapshots не существует (FDW-обёртки только над gendesign-таблицами); +-- в information_schema.columns имя встречается ровно в этой таблице. В коде: ни +-- одного SELECT-читателя (`SELECT *` по таблице нигде нет), фронт/экспортёры/админка +-- колонку не упоминают. Единственный сторож — tests/test_snapshot_writer.py, он +-- проверяет отсутствие колонки у писателя и после DROP остаётся валиден. + +BEGIN; + +ALTER TABLE listings_snapshots DROP COLUMN IF EXISTS position_in_serp; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql b/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql new file mode 100644 index 00000000..71b4cd62 --- /dev/null +++ b/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql @@ -0,0 +1,66 @@ +-- 228_scrape_proxies_browser_health.sql +-- Здоровье узла ОТДЕЛЬНО для браузерного тракта (#2723). +-- +-- WHY: +-- `run_proxy_healthcheck` гоняет через узел обычный httpx-GET к ipify. Боевой сбор +-- Авито с 02.08 (#2637) ходит через сайдкар браузером: camoufox стартует С ЭТИМ +-- прокси (geoip-lookup на launch), потом навигация. Это разные свойства узла: +-- крошечный GET проходит там, где launch/навигация падает (`browser unavailable +-- (proxy may be down)` — все 90 записанных обрывов сбора именно такие). +-- +-- Хуже того, оба свойства писались в ОДИН счётчик: боевой /fetch репортит +-- mark_health(ok=False) → consecutive_fails++, но следующая (≤30 мин) успешная +-- ipify-проба делает consecutive_fails=0 + enabled=true. Дешёвая проба СТИРАЛА +-- вердикт дорогого тракта, и узел, мёртвый для браузера, вечно возвращался в +-- выдачу. Это ровно ошибка #2686 (схлопывание двух диагнозов в один флаг) в +-- другом месте; разводим её тем же приёмом, что #2711 (`scrape_runs.ban_kind`) — +-- поле РЯДОМ, а не новое значение существующего флага. +-- +-- WHAT (три колонки, ни одна не участвует в enabled/consecutive_fails): +-- browser_fail_streak — подряд-провалы ИМЕННО браузерной пробы, и только те, что +-- атрибутируются узлу (сайдкар лежит / страница пустая — +-- не считаются, см. proxy_pool._classify_browser_probe). +-- Успешная ipify-проба его НЕ обнуляет — в этом весь смысл. +-- browser_unfit_since — момент, когда streak дошёл до порога. NOT NULL = «жив для +-- HTTP, непригоден для браузера». acquire() такой узел НЕ +-- исключает (голодание хуже — #2600/#2638, пул 4 узла), а +-- отправляет в КОНЕЦ очереди выдачи: его возьмут, только +-- если свободных пригодных нет. +-- browser_check_at — когда браузерную пробу гоняли последний раз. Такт у неё +-- свой, редкий (BROWSER_PROBE_MINUTES): она стоит запуска +-- camoufox (~8с замерено на проде), ipify — миллисекунды. +-- +-- IDEMPOTENCY / SAFETY: +-- ADD COLUMN IF NOT EXISTS × 3, аддитивно, без backfill'а: NULL/0 = «браузерную +-- пробу ещё не гоняли», ровно то состояние, в котором пул и находится. Ни одна +-- существующая выборка не меняет результат (все три колонки новые). Повторный +-- прогон — no-op (auto-apply strict на деплое это требует). +-- +-- Dependencies: 157_scrape_proxies.sql + +BEGIN; + +ALTER TABLE scrape_proxies + ADD COLUMN IF NOT EXISTS browser_fail_streak integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS browser_unfit_since timestamptz, + ADD COLUMN IF NOT EXISTS browser_check_at timestamptz; + +COMMENT ON COLUMN scrape_proxies.browser_fail_streak IS + 'Подряд-провалы браузерной пробы (сайдкар + camoufox через ЭТОТ узел), ' + 'атрибутированные узлу. НЕ обнуляется успешной ipify-пробой — иначе дешёвая ' + 'проба стирает вердикт дорогого тракта (#2723). Обнуляется успешной браузерной ' + 'пробой. Порог → browser_unfit_since, см. proxy_pool.BROWSER_UNFIT_THRESHOLD.'; + +COMMENT ON COLUMN scrape_proxies.browser_unfit_since IS + 'NOT NULL = узел жив для обычного HTTP, но браузерный тракт через него не ' + 'работает (#2723). Это НЕ enabled=false: узел остаётся в пуле и обслуживает ' + 'curl-путь, а acquire() лишь отдаёт его последним. Полное выключение по-прежнему ' + 'значит «узел мёртв целиком» (серия транспортных сбоев) либо решение оператора.'; + +COMMENT ON COLUMN scrape_proxies.browser_check_at IS + 'Последняя браузерная проба. Такт свой, редкий (proxy_pool.BROWSER_PROBE_MINUTES): ' + 'одна такая проба = запуск camoufox (~8с на проде), против миллисекунд у ipify. ' + 'На неподтверждённом провале НЕ обновляется — чтобы следующий же цикл ' + 'healthcheck подтвердил/опроверг отказ, а не ждал полный такт.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql b/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql new file mode 100644 index 00000000..482f9405 --- /dev/null +++ b/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql @@ -0,0 +1,93 @@ +-- 229_trade_in_estimates_consent_proof.sql +-- ЭТАП 4 B2C launch — правовая рамка для анонимных пользователей (152-ФЗ). +-- +-- WHY: +-- trade_in_estimates.address (NOT NULL) сохраняется на КАЖДОЙ оценке, но до +-- сих пор в кодовой базе не было ни единой проверки согласия ДО этого +-- сохранения — ни на уровне схемы, ни на уровне API. Для авторизованного +-- B2B-пилота это было терпимо (согласие закрыто договором), но сегодняшняя +-- схема совершенно не готова к анонимному B2C-пользователю "с улицы": для +-- него согласия не существует вообще, ни в БД, ни в договоре. +-- +-- Прецедент уже есть и работает: migration 182 добавила на trade_in_leads +-- durable-доказательство согласия (client_ip / consent_policy_version / +-- consent_text_snapshot) вместо голого boolean + audit-лога. Этот файл +-- переиспользует РОВНО ТУ ЖЕ форму для trade_in_estimates — согласованность +-- схемы для одного и того же понятия (152-ФЗ proof-of-consent) в двух +-- соседних таблицах важнее гипотетической экономии на отдельной events- +-- таблице (см. разбор формы хранения в PR/задаче ЭТАП 4). +-- +-- WHAT: +-- Четыре nullable-колонки на trade_in_estimates: +-- - consent (boolean) — True для анонимных запросов, что +-- прошли consent-gate в +-- estimate_quality() (app/services/ +-- estimator.py). NULL для B2B-пилотов +-- (created_by задан) — их согласие +-- закрыто договором, НЕ UI-чекбоксом, +-- и мы НЕ подделываем доказательство, +-- которое реально не собиралось. +-- - client_ip (inet) — клиентский IP анонимного запроса на +-- момент согласия. +-- - consent_policy_version (text) — снимок _ESTIMATE_CONSENT_POLICY_VERSION +-- (estimator.py) на момент согласия. +-- - consent_text_snapshot (text) — снимок точного текста согласия, +-- показанного пользователю +-- (_ESTIMATE_CONSENT_TEXT_SNAPSHOT). +-- +-- CHECK-констрейнт: consent IS NULL OR consent IS TRUE — на уровне схемы +-- защищает от того, чтобы False-согласие когда-либо попало в БД (сама +-- проверка в estimate_quality() уже не пускает False дальше 422, это +-- defense-in-depth на случай будущего кода, который забудет про gate). +-- +-- Индекс на expires_at — обслуживает будущую retention-задачу +-- purge_expired_trade_in_data (см. migration 231), которая физически +-- удаляет строки, чей expires_at истёк (сегодня expires_at используется +-- ТОЛЬКО как read-time фильтр, см. GET /estimate/{id}: "AND expires_at > +-- NOW()" — без индекса такой batched-DELETE делал бы full scan таблицы +-- на каждый ночной прогон). +-- +-- IDEMPOTENCY / SAFETY: +-- - ADD COLUMN IF NOT EXISTS x4 — безопасный re-run, все nullable, без +-- DEFAULT, без backfill (существующие строки остаются NULL — честное +-- отражение того, что доказательство согласия для них НЕ собиралось, +-- не искусственная порча схемы). +-- - CHECK-констрейнт добавлен через DO-блок с проверкой pg_constraint по +-- имени (Postgres не поддерживает `ADD CONSTRAINT IF NOT EXISTS` +-- напрямую) — паттерн 1:1 из 189_account_estimate_usage_nonnegative.sql. +-- - CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- - Чисто additive: ничего существующего не читается/не переписывается. +-- +-- Dependencies: 001_trade_in_estimates.sql (таблица), +-- 083_trade_in_estimates_created_by.sql (created_by, использован в gate), +-- 182_trade_in_leads_consent_proof.sql (форма-прецедент для trade_in_leads). + +BEGIN; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS consent boolean, + ADD COLUMN IF NOT EXISTS client_ip inet, + ADD COLUMN IF NOT EXISTS consent_policy_version text, + ADD COLUMN IF NOT EXISTS consent_text_snapshot text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'trade_in_estimates_consent_not_false' + ) THEN + ALTER TABLE trade_in_estimates + ADD CONSTRAINT trade_in_estimates_consent_not_false + CHECK (consent IS NULL OR consent IS TRUE); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS trade_in_estimates_expires_at_idx + ON trade_in_estimates (expires_at); + +COMMENT ON COLUMN trade_in_estimates.consent IS '152-ФЗ proof-of-consent (ЭТАП 4 B2C): TRUE для анонимных запросов, прошедших consent-gate в estimate_quality(). NULL для B2B-пилотов (created_by задан) — согласие закрыто договором, доказательство здесь не собирается.'; +COMMENT ON COLUMN trade_in_estimates.client_ip IS '152-ФЗ proof-of-consent: клиентский IP анонимного запроса на момент согласия (см. app/api/v1/trade_in.py::estimate, _client_ip). NULL для B2B-пилотов.'; +COMMENT ON COLUMN trade_in_estimates.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _ESTIMATE_CONSENT_POLICY_VERSION (app/services/estimator.py) на момент согласия.'; +COMMENT ON COLUMN trade_in_estimates.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия на ОЦЕНКУ, показанного пользователю (_ESTIMATE_CONSENT_TEXT_SNAPSHOT) — отдельный текст от trade_in_leads.consent_text_snapshot (тот про согласие на контакт-заявку, другой предмет обработки).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/230_house_merge_log.sql b/tradein-mvp/backend/data/sql/230_house_merge_log.sql new file mode 100644 index 00000000..bf55ec6b --- /dev/null +++ b/tradein-mvp/backend/data/sql/230_house_merge_log.sql @@ -0,0 +1,262 @@ +-- 230_house_merge_log.sql +-- Журнал слияний домов + обратная операция (#2690). +-- +-- WHY: +-- `house_dedup_merge` — НЕ спящая идея, а живой деструктивный проход: расписание +-- `house_dedup_merge` на проде enabled=true, dry_run=false, такт 7 дней. Шесть прогонов +-- с 2026-06-27 уже удалили 119 строк `houses` (счётчики losers_deleted в scrape_runs: +-- 2/39/31/9/6/32). Единственным следом «кто в кого» была строка `logger.info` в контейнере, +-- а логи ротируются быстрее суток. То есть **уже сегодня** нельзя назвать, какой дом в какой +-- свернули 1 августа, — не говоря о том, чтобы вернуть. +-- +-- Пока этого журнала нет, любой разговор о расширении ключа схлопывания (#2690, #1772) +-- ведётся без права на ошибку: единственный откат — restore всей БД на момент до прогона, +-- т.е. выброс недели сбора. Журнал снимает это условие: слияние становится обратимым, +-- и вопрос о ключе можно пересматривать, а не решать «навсегда». +-- +-- Правку НЕ следует читать как одобрение текущего ключа/победителя/гео-стража. Она к ним +-- НЕЙТРАЛЬНА: ни ключ, ни правило выбора победителя, ни гео-страж здесь не меняются. +-- Меняется только одно — теперь есть что откатить. +-- +-- WHAT (одна строка = один проигравший дом): +-- merge_pass / cluster_key / geo_guard / distance_m — ОСНОВАНИЕ слияния. Это не косметика: +-- ровно этих полей не хватило в #2690, чтобы ответить на вопрос «сколько слияний прошло +-- на расстояниях, которые гео-страж заблокировал бы» по ДАННЫМ, а не по ревью. distance_m +-- пишется всегда, даже когда страж для прохода выключен (fias-проход) — тогда он и есть +-- единственная запись о том, насколько далеко разъехались объединённые дома. +-- loser_row — ПОЛНЫЙ jsonb-снимок удаляемой строки (`to_jsonb(h.*)`, все 86 колонок). +-- Ссылка на удалённую строку бесполезна, поэтому хранится содержимое. Снимок целиком, +-- а не список полей: проверено, что `jsonb_populate_record(NULL::houses, loser_row)` +-- восстанавливает строку побайтово, включая PostGIS-geom (to_jsonb отдаёт её GeoJSON'ом, +-- populate_record разбирает обратно входной функцией типа). Побочная выгода: новая +-- колонка в `houses` попадает в снимок и в откат САМА, без правки этой миграции. +-- keeper_before — снимок ПОБЕДИТЕЛЯ до переноса метаданных. Нужен, потому что слияние не +-- только удаляет проигравшего: `_CARRY_OVER_IDENTITY_SQL` дозаполняет победителю NULL-поля +-- идентичности (fias/кадастр/ГАР/DaData) значениями проигравшего. Без этого снимка откат +-- вернул бы дом, но оставил бы его ФИАС на победителе — и следующий же fias-проход слил +-- бы их обратно. +-- children_repointed — {"таблица.колонка": [id, ...]}. Дочерние строки ПЕРЕЖИЛИ слияние, +-- у них сменилась только ссылка, поэтому хранятся id, а не содержимое (иначе одни +-- listings с их raw-payload'ом дали бы ~7 КБ на строку вместо ~8 байт на id). +-- children_deleted — {"таблица": [{строка целиком}, ...]}. Дочерние строки, которые проход +-- УДАЛИЛ из-за коллизии по UNIQUE. Их содержимое уничтожено, id недостаточно — только +-- полный снимок. Таких таблиц шесть (см. _STEPS), строки мелкие. +-- batch_id — один вызов merge_duplicate_houses() (оба прохода). Единица отката. +-- run_id / initiator — кто инициировал: scrape_runs.id для расписания, NULL для ручного. +-- +-- НАМЕРЕННО БЕЗ ВНЕШНИХ КЛЮЧЕЙ на houses(id) и scrape_runs(id): +-- журнал обязан ПЕРЕЖИВАТЬ строки, которые описывает. loser_id указывает на заведомо +-- удалённый дом. keeper_id — на дом, который сам может быть слит следующим прогоном; FK +-- с CASCADE стёр бы историю ровно тогда, когда она нужнее всего, а FK без CASCADE +-- заблокировал бы слияние. То же с run_id: чистка scrape_runs не должна трогать журнал. +-- +-- ОБЪЁМ (замерено на проде 2026-08-06): +-- 9 571 дом, средняя строка houses в jsonb 2 581 Б. Строка журнала ≈ loser_row 2.5 КБ + +-- keeper_before 2.5 КБ + списки id (в среднем 27.9 дочерних строк на дом × ~8 Б) ≈ 5.3 КБ. +-- Наблюдаемый темп — 20 слияний в неделю (119 за 6 прогонов) ≈ 106 КБ/нед ≈ 5.5 МБ/год. +-- Ближайший прогон (замер тем же выражением, что и код): 93 проигравших ≈ 0.5 МБ. +-- Абсолютный потолок, если схлопнуть вообще все дома: 9 571 × 5.3 КБ ≈ 50 МБ против 23 МБ +-- самой таблицы houses. +-- +-- RETENTION: НЕ НУЖЕН, сознательно. Потолок роста — двузначные мегабайты, то есть дешевле +-- любой процедуры чистки; а журнал слияний — это ровно то, что удалять не хочется: его +-- ценность в том, что он отвечает на вопрос «что было год назад», когда логов давно нет. +-- Если объём когда-нибудь станет проблемой, удалять надо не строки, а тяжёлые снимки +-- (loser_row/keeper_before → NULL) у записей старше N лет, сохранив соответствие +-- loser→keeper: оно весит байты и именно оно нужно дольше всего. +-- +-- Dependencies: 002_core_tables.sql (houses), 135_scrape_schedules_seed_house_dedup_merge.sql +-- Пишется в ТОЙ ЖЕ транзакции, что и слияние (см. house_dedup_merge._run_merge_pass) — +-- разрыв «слияние прошло, запись не легла» невозможен по построению; dry_run откатывает и то, +-- и другое вместе. + +BEGIN; + +CREATE TABLE IF NOT EXISTS house_merge_log ( + id bigserial PRIMARY KEY, + merged_at timestamptz NOT NULL DEFAULT now(), + batch_id uuid NOT NULL, + run_id bigint, + initiator text NOT NULL, + merge_pass text NOT NULL, + cluster_key text NOT NULL, + geo_guard boolean NOT NULL, + distance_m double precision, + norm_address text, + loser_id bigint NOT NULL, + keeper_id bigint NOT NULL, + loser_row jsonb NOT NULL, + keeper_before jsonb NOT NULL, + children_repointed jsonb NOT NULL DEFAULT '{}'::jsonb, + children_deleted jsonb NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_house_merge_log_loser ON house_merge_log (loser_id); +CREATE INDEX IF NOT EXISTS idx_house_merge_log_keeper ON house_merge_log (keeper_id); +CREATE INDEX IF NOT EXISTS idx_house_merge_log_batch ON house_merge_log (batch_id); + +COMMENT ON TABLE house_merge_log IS + 'Журнал слияний домов (#2690): одна строка = один проигравший дом, удалённый проходом ' + 'house_dedup_merge. Пишется в ТОЙ ЖЕ транзакции, что и слияние. Содержит полный снимок ' + 'удалённой строки и перечень перенесённых/удалённых дочерних строк — достаточно, чтобы ' + 'назвать поимённо, что во что свернули, и вернуть обратно (house_merge_undo). Намеренно ' + 'БЕЗ FK на houses/scrape_runs: журнал переживает строки, которые описывает. Retention нет.'; + +COMMENT ON COLUMN house_merge_log.cluster_key IS + 'ЗНАЧЕНИЕ ключа, по которому дома попали в один кластер («addr:вайнера66» / «fias:»), ' + 'а не имя ключа — по нему видно, какое именно совпадение сработало.'; +COMMENT ON COLUMN house_merge_log.geo_guard IS + 'Был ли для этого прохода включён гео-страж 250 м. false = слияние разрешено БЕЗ проверки ' + 'близости; вместе с distance_m это и есть аудит основания (#2690).'; +COMMENT ON COLUMN house_merge_log.distance_m IS + 'ST_DistanceSphere между победителем и проигравшим на момент слияния; NULL = у одной из ' + 'сторон не было geom. Пишется ВСЕГДА, в том числе когда гео-страж выключен.'; +COMMENT ON COLUMN house_merge_log.loser_row IS + 'to_jsonb() удалённой строки houses целиком. Восстановление: ' + 'INSERT INTO houses SELECT r.* FROM jsonb_populate_record(NULL::houses, loser_row) r.'; +COMMENT ON COLUMN house_merge_log.keeper_before IS + 'Снимок победителя ДО переноса метаданных с проигравшего (COALESCE-дозаполнение полей ' + 'идентичности). Без него откат вернул бы дом, но оставил его ФИАС/кадастр на победителе.'; +COMMENT ON COLUMN house_merge_log.children_repointed IS + '{"таблица.колонка": [id, ...]} — дочерние строки, у которых слияние сменило ссылку ' + 'loser→keeper. Строки целы, поэтому хранятся id: откат возвращает ссылку обратно.'; +COMMENT ON COLUMN house_merge_log.children_deleted IS + '{"таблица": [{строка целиком}, ...]} — дочерние строки, УДАЛЁННЫЕ проходом из-за коллизии ' + 'по UNIQUE с победителем. Содержимое уничтожено, поэтому хранится снимок, а не id.'; + +-- ── Обратная операция ──────────────────────────────────────────────────────── +-- +-- Откат одного батча (или его части) по журналу. Транзакционен: вызывающий сам решает +-- COMMIT/ROLLBACK, увидев отчёт. Возвращает СТРОКУ НА КАЖДУЮ запись журнала со статусом — +-- в том числе «не смог», потому что молчаливо-успешный откат хуже отсутствующего. +-- +-- Порядок внутри одной записи важен: сначала воскресить дом (на него ссылаются дети), потом +-- вернуть ссылки детей, потом вернуть удалённых детей, потом снять перенос метаданных с +-- победителя. Записи батча обходятся в обратном порядке (id DESC) — если дом A слили в B, +-- а B потом в C, разматывать надо с конца. +-- +-- ИЗВЕСТНЫЕ ГРАНИЦЫ (сознательные, отражены в статусе): +-- * дочерняя строка, удалённая по коллизии, может не вернуться: место в UNIQUE-ключе занято +-- строкой победителя. ON CONFLICT DO NOTHING + счётчик в статусе, а не тихая потеря; +-- * backfill-строки house_sources/house_address_aliases, которые проход дописал победителю, +-- НЕ удаляются: они собраны из собственных полей победителя и остались бы верны и без +-- слияния; +-- * если id проигравшего уже занят — запись пропускается со статусом, откат не гадает. +CREATE OR REPLACE FUNCTION house_merge_undo( + p_batch uuid, + p_only_losers bigint[] DEFAULT NULL +) +RETURNS TABLE ( + out_log_id bigint, + out_loser_id bigint, + out_keeper_id bigint, + out_status text +) +LANGUAGE plpgsql +AS $$ +DECLARE + rec record; + v_table text; + v_column text; + v_ids bigint[]; + v_rows jsonb; + v_field text; + v_repointed int; + v_restored int; + v_lost int; + v_n int; + -- Список полей ДОЛЖЕН совпадать с SET в house_dedup_merge._CARRY_OVER_IDENTITY_SQL; + -- за расхождением следит тест test_undo_carryover_fields_match_merge_carryover. + c_carry_fields constant text[] := ARRAY[ + 'house_fias_id', 'cadastral_number', 'gar_house_guid', 'gar_flat_count', + 'gar_matched_at', 'gar_match_method', 'dadata_qc_geo', 'dadata_qc_house', + 'dadata_enriched_at' + ]; +BEGIN + FOR rec IN + SELECT * + FROM house_merge_log l + WHERE l.batch_id = p_batch + AND (p_only_losers IS NULL OR l.loser_id = ANY (p_only_losers)) + ORDER BY l.id DESC + LOOP + out_log_id := rec.id; + out_loser_id := rec.loser_id; + out_keeper_id := rec.keeper_id; + + IF EXISTS (SELECT 1 FROM houses h WHERE h.id = rec.loser_id) THEN + out_status := 'skipped: houses.id ' || rec.loser_id || ' занят — уже откачено?'; + RETURN NEXT; + CONTINUE; + END IF; + + -- 1. Воскресить проигравшего целиком из снимка (все колонки, включая geom). + INSERT INTO houses + SELECT r.* FROM jsonb_populate_record(NULL::houses, rec.loser_row) r; + + -- 2. Вернуть ссылки уцелевших детей. Условие «сейчас указывает на победителя» + -- защищает от затирания строк, которые после слияния перепривязали чем-то ещё. + v_repointed := 0; + FOR v_table, v_column, v_ids IN + SELECT split_part(e.key, '.', 1), + split_part(e.key, '.', 2), + ARRAY(SELECT jsonb_array_elements_text(e.value)::bigint) + FROM jsonb_each(rec.children_repointed) AS e + LOOP + EXECUTE format( + 'UPDATE %I SET %I = $1 WHERE id = ANY ($2) AND %I = $3', + v_table, v_column, v_column + ) USING rec.loser_id, v_ids, rec.keeper_id; + GET DIAGNOSTICS v_n = ROW_COUNT; + v_repointed := v_repointed + v_n; + END LOOP; + + -- 3. Вернуть детей, удалённых по коллизии UNIQUE. Место могло остаться занятым + -- строкой победителя — тогда DO NOTHING, и это попадёт в отчёт как «не вернулось». + v_restored := 0; + v_lost := 0; + FOR v_table, v_rows IN + SELECT e.key, e.value FROM jsonb_each(rec.children_deleted) AS e + LOOP + EXECUTE format( + 'INSERT INTO %I SELECT r.* FROM jsonb_array_elements($1) AS el, ' + 'LATERAL jsonb_populate_record(NULL::%I, el) r ON CONFLICT DO NOTHING', + v_table, v_table + ) USING v_rows; + GET DIAGNOSTICS v_n = ROW_COUNT; + v_restored := v_restored + v_n; + v_lost := v_lost + (jsonb_array_length(v_rows) - v_n); + END LOOP; + + -- 4. Снять перенос метаданных с победителя. Только там, где до слияния было NULL И + -- текущее значение всё ещё РОВНО то, что принёс этот проигравший: если поле успел + -- заполнить загрузчик (или донором был другой проигравший кластера) — не трогаем. + -- Сравнение в jsonb-пространстве, чтобы один цикл покрыл text/int/timestamptz. + FOREACH v_field IN ARRAY c_carry_fields LOOP + IF rec.keeper_before ->> v_field IS NULL THEN + EXECUTE format( + 'UPDATE houses SET %I = NULL WHERE id = $1 AND to_jsonb(%I) = $2', + v_field, v_field + ) USING rec.keeper_id, rec.loser_row -> v_field; + END IF; + END LOOP; + + out_status := format( + 'restored: дом %s вернулся, ссылок возвращено %s, дочерних строк восстановлено %s' + || CASE WHEN v_lost > 0 THEN ', НЕ ВЕРНУЛОСЬ ' || v_lost || ' (место занято)' + ELSE '' END, + rec.loser_id, v_repointed, v_restored + ); + RETURN NEXT; + END LOOP; +END; +$$; + +COMMENT ON FUNCTION house_merge_undo(uuid, bigint[]) IS + 'Откат слияния домов по журналу house_merge_log (#2690). Аргументы: batch_id (единица ' + 'отката = один вызов merge_duplicate_houses) и опциональный список loser_id для частичного ' + 'отката. Возвращает строку-статус на КАЖДУЮ запись журнала, включая неудачные. ' + 'Транзакции не открывает и не закрывает — вызывающий смотрит отчёт и решает COMMIT/ROLLBACK: ' + ' BEGIN; SELECT * FROM house_merge_undo(''''); -- прочитать статусы -- COMMIT;'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql b/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql new file mode 100644 index 00000000..b3a3995b --- /dev/null +++ b/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql @@ -0,0 +1,119 @@ +-- 231_trade_in_privacy_retention.sql +-- ЭТАП 4 B2C launch — retention enforcement (152-ФЗ): срок хранения ДОЛЖЕН +-- приводить к физическому удалению, а не быть декоративным полем. +-- +-- WHY: +-- trade_in_estimates.expires_at существовал (migration 004), но применялся +-- ТОЛЬКО как read-time фильтр (GET /estimate/{id}: "AND expires_at > NOW()") +-- — ни одна фоновая задача не удаляла строки после истечения TTL. Данные +-- физлица (адрес) жили в БД бессрочно вопреки декларированному сроку. +-- trade_in_leads было ещё хуже: там вообще НЕ было понятия TTL/expires_at — +-- телефон + согласие хранились бессрочно с момента создания таблицы +-- (172_trade_in_leads.sql). +-- +-- WHAT: +-- 1. trade_in_leads.expires_at (timestamptz NOT NULL) — backfill для +-- существующих строк = created_at + 180 дней (тот же default, что +-- settings.trade_in_lead_retention_days в app/core/config.py на момент +-- этой миграции; 180 дней = рабочий MVP-default для НЕконвертированных +-- маркетинговых лидов, см. обоснование в config.py — конкретный +-- юридически обоснованный срок хранения это решение DPO/юриста, не +-- инженера). Новые строки получают expires_at на insert-time +-- (app/api/v1/lead.py, тем же паттерном, что trade_in_estimates). +-- 2. Индекс на trade_in_leads.expires_at — для batched-DELETE ниже. +-- 3. scrape_schedules seed: purge_expired_trade_in_data — ночная задача +-- (app/tasks/purge_expired_trade_in_data.py, kit-handler в +-- app/services/product_handlers.py), физически удаляющая ИСТЁКШИЕ +-- строки в trade_in_estimates И trade_in_leads пачками (batch_size из +-- default_params, лимит max_batches за один прогон — см. таск-докстринг). +-- ON DELETE CASCADE (007_estimate_photos, 018_avito_imv_evaluations) и +-- ON DELETE SET NULL (172_trade_in_leads.estimate_id) уже подчищают +-- зависимые таблицы автоматически — этот файл их не трогает. +-- +-- Seeded с enabled=false (тот же осторожный паттерн, что +-- 175_scrape_schedules_seed_domclick_detail_backfill.sql): это ПЕРВАЯ +-- автоматическая задача физического DELETE персональных данных в trade-in — +-- заслуживает supervised первого прогона (смотри логи/counters вручную) +-- перед тем, как доверить её расписанию. +-- +-- ⚠️ ПЕРЕД ВКЛЮЧЕНИЕМ (deep-review 2026-08-06, HIGH — читай целиком, не +-- только команду в конце): задача удаляет строки trade_in_estimates +-- физически и НЕОБРАТИМО. DELETE ограничен `created_by IS NULL` — только +-- анонимные B2C-оценки (см. докстринг app/tasks/purge_expired_trade_in_ +-- data.py, раздел про асимметрию expires_at = TTL ссылки/PDF, а не срок +-- хранения строки) — строки B2B-пилотов (`created_by` задан) задача НЕ +-- трогает НИКОГДА, что бы ни стояло в expires_at. Аудит на проде на момент +-- этой правки: из 1057 строк trade_in_estimates 1040 уже просрочены +-- (expires_at < NOW()), но ТОЛЬКО 129 из них имеют created_by IS NULL +-- (реальная B2C-популяция под удаление) — остальные 911 принадлежат +-- пилотам (admin/kopylov/brusnika/praktika/pilottest/admintest/user1) и +-- гвардом защищены от удаления. Эти цифры УСТАРЕЮТ — не включай задачу по +-- их памяти. Перед `UPDATE scrape_schedules SET enabled=true WHERE +-- source='purge_expired_trade_in_data'`: +-- 1. Свежий `SELECT count(*) FROM trade_in_estimates WHERE expires_at +-- < NOW() AND created_by IS NULL` — если число на порядок больше +-- 129 (или created_by-гвард почему-то отсутствует в текущем коде +-- задачи) — СТОП, разберись, прежде чем включать. +-- 2. Прогони задачу вручную ОДИН раз (app/tasks/ +-- purge_expired_trade_in_data.py, синхронная функция) и сверь +-- counters с п.1 (задача логирует batch/running_total через +-- logger.info в _drain_expired) — supervised первый прогон, не +-- включение вслепую. +-- 3. Только после совпадения счётчиков — enable в scrape_schedules; +-- расписание подхватит на следующем тике планировщика (крутится в +-- контейнере tradein-scraper, не tradein-backend). +-- Включение — отдельный ручной шаг, эта миграция его НЕ делает. +-- +-- IDEMPOTENCY / SAFETY: +-- - ADD COLUMN IF NOT EXISTS + UPDATE ... WHERE expires_at IS NULL (no-op на +-- повторном прогоне, все строки уже проставлены) + +-- ALTER COLUMN ... SET NOT NULL (идемпотентно само по себе — Postgres не +-- ошибается на повторной установке уже действующего NOT NULL). +-- - CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- - INSERT ... ON CONFLICT (source) DO NOTHING — безопасный re-run seed'а. +-- +-- Dependencies: 172_trade_in_leads.sql (таблица), 052_scrape_schedules.sql +-- (scrape_schedules), 229_trade_in_estimates_consent_proof.sql (соседняя +-- часть той же ЭТАП 4 инициативы — индекс на trade_in_estimates.expires_at +-- уже создан там). + +BEGIN; + +ALTER TABLE trade_in_leads + ADD COLUMN IF NOT EXISTS expires_at timestamptz; + +UPDATE trade_in_leads +SET expires_at = created_at + interval '180 days' +WHERE expires_at IS NULL; + +ALTER TABLE trade_in_leads + ALTER COLUMN expires_at SET NOT NULL; + +CREATE INDEX IF NOT EXISTS trade_in_leads_expires_at_idx + ON trade_in_leads (expires_at); + +COMMENT ON COLUMN trade_in_leads.expires_at IS 'ЭТАП 4 B2C (152-ФЗ): срок хранения лида. Backfill для legacy-строк = created_at + 180 дней; новые строки считаются на insert-time из settings.trade_in_lead_retention_days (app/api/v1/lead.py). Физическое удаление после истечения — app/tasks/purge_expired_trade_in_data.py.'; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'purge_expired_trade_in_data', + false, + 2, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"batch_size": 500, "max_batches": 20}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (replaces cron-script setup). Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), yandex_address_backfill (#855, EKB pilot), sber_index_pull (#887, monthly), rosreestr_quarter_poll (#888, monthly), cian_city_sweep (dormant, #973), yandex_newbuilding_sweep (dormant, #974), geocode_missing_listings (#1: listings geom backfill, all sources), avito_detail_backfill (#1551: nightly detail-enrichment backfill for legacy avito listings), domclick_detail_backfill (#2000: nightly Layer B detail-enrichment backfill for domklik listings, cookie-injection + QRATOR-aware, disabled by default until smoke-tested), purge_expired_trade_in_data (ЭТАП 4 B2C: nightly batched physical DELETE of expired trade_in_estimates/trade_in_leads rows, disabled by default until a supervised first run).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql b/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql new file mode 100644 index 00000000..adf9e522 --- /dev/null +++ b/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql @@ -0,0 +1,84 @@ +-- 232_listings_observation_time_meaning.sql +-- Purpose (#2731): зафиксировать в схеме, что содержательные метки наблюдения до +-- этой правки несли время НАЧАЛА транзакции сбора, а не момент наблюдения строки, +-- и назвать дату, с которой их смысл изменился. +-- +-- Dependencies: 002_core_tables.sql (listings.scraped_at/last_seen_at), +-- 016_listings_snapshots.sql (listings_snapshots.observed_at), +-- 161_backfill_scraped_at_active_recent.sql (ретро-выравнивание scraped_at по last_seen_at). +-- Apply after: 230_house_merge_log.sql +-- Идемпотентно: только COMMENT ON COLUMN (перезаписывает сам себя), данных не трогает. +-- +-- ── ЧТО БЫЛО ───────────────────────────────────────────────────────────────── +-- Писатель объявлений (packages/scraper-kit/.../base.py::save_listings + snapshot_writer) +-- ставил NOW(). В PostgreSQL NOW() == transaction_timestamp() — время старта транзакции. +-- save_listings коммитит ОДИН раз в конце всего batch'а, поэтому одну метку получали все +-- строки одного вызова, сколько бы он ни работал. +-- +-- Прод-замер 2026-08-06 (listings_snapshots, строк / различных меток на прогон): +-- run 3303 — 219 / 1 (прогон шёл 7 минут, все метки на нулевой секунде); +-- run 3299 — 235 / 1; run 3293 — 297 / 1; +-- run 3229 — 2643 / 55 (метка на вызов save_listings, а не на строку). +-- listings за те же сутки: 219/1, 235/1, 297/1 … — и scraped_at, и last_seen_at, причём +-- у 100% строк они РАВНЫ между собой (замер по часам: eq == rows во всех корзинах). +-- +-- ── ЧЕГО ЭТО НЕ ЛОМАЛО ─────────────────────────────────────────────────────── +-- Фильтры свежести и TTL — НЕ искажены. Смещение равно длительности прогона: типично +-- минуты, худший случай на проде 17.3 ч (cian_full_load). Против окна свежести в 14 суток +-- это 0.03-5%. Утверждение «долгие прогоны ломают фильтр свежести» проверено и снято. +-- +-- ── ЧТО ЭТО ЛОМАЛО ─────────────────────────────────────────────────────────── +-- Разрешение во времени. По данным нельзя восстановить ни темп сбора, ни порядок строк +-- внутри прогона: все они выглядят одномоментными. Отсюда же следовала невозможность +-- бэкфилла run_id (#2701). Ошибка тихая — значения правдоподобны. +-- +-- ── ПОЧЕМУ ИСТОРИЮ НЕ ЧИНИМ ────────────────────────────────────────────────── +-- Внутрипрогонное время НИГДЕ БОЛЬШЕ НЕ СОХРАНЯЛОСЬ: у прогона есть только started_at и +-- finished_at, а распределение строк между ними неизвестно. Строки ДО перехода +-- невосстановимы — их метки помечаются, а не переписываются. +-- +-- ── ПОЧЕМУ statement_timestamp(), А НЕ clock_timestamp() ───────────────────── +-- В #2702/#2718 (служебные колонки прогона) взяли clock_timestamp() — там колонка одна. +-- Здесь в одном statement'е пишутся ДВЕ колонки, и они обязаны совпадать: после #2206 +-- scraped_at и last_seen_at равны, и на этом равенстве стоит предикат миграции 161 +-- (`WHERE last_seen_at > scraped_at` как признак «видели живым, но не пере-скрейпили»). +-- Проверка на проде: `clock_timestamp() = clock_timestamp()` → false, +-- `statement_timestamp() = statement_timestamp()` → true. При этом statement_timestamp() +-- двигается ОТ STATEMENT'А К STATEMENT'У внутри одной транзакции (проверено: 1.2 с между +-- соседними запросами при неподвижном now()), а каждый upsert объявления — свой statement. +-- Итог: построчная метка без ложного расхождения колонок. +-- +-- Set-based писатели (listing_source_snapshots, deactivate_stale_avito) НАМЕРЕННО оставлены +-- на now(): там один statement пишет десятки тысяч строк, и одна метка — это правда о нём. +-- clock_timestamp() выдал бы там ложное разрешение: на проде +-- `count(DISTINCT clock_timestamp())` по 200 000 строк одного statement'а = 27 783 разных +-- значения, кодирующих порядок обработки строк планировщиком, а не порядок наблюдения. + +BEGIN; + +COMMENT ON COLUMN listings_snapshots.observed_at IS + 'Момент наблюдения снимка. С #2731 (2026-08-06) — statement_timestamp(), то есть ' + 'время записи КОНКРЕТНОЙ строки. У строк ДО этой даты значение общее на весь вызов ' + 'save_listings (писалось now() = старт транзакции batch''а): на проде 219 снимков ' + 'семиминутного прогона несли одну метку. Для строк до перехода observed_at читать как ' + '«прогон, в котором строку увидели», а НЕ как момент; темп сбора и порядок строк ' + 'внутри прогона по историческим данным невосстановимы — внутрипрогонное время нигде ' + 'не сохранялось.'; + +COMMENT ON COLUMN listings.scraped_at IS + 'Момент последнего скрейпа объявления. С #2206 двигается и при ре-подтверждении живым ' + '(не только при вставке), с #2731 (2026-08-06) пишется statement_timestamp() — ' + 'построчно. У строк ДО этой даты — время старта транзакции batch''а, общее на все ' + 'строки вызова save_listings. На фильтры свежести это не влияло: смещение равно ' + 'длительности прогона (минуты; худший случай 17.3 ч у cian_full_load) против окна в ' + '14 суток. Невосстановимо потеряно другое — разрешение внутри прогона.'; + +COMMENT ON COLUMN listings.last_seen_at IS + 'Момент последнего подтверждения, что объявление живо. Пишется тем же statement''ом, ' + 'что и scraped_at, и потому РАВЕН ему — это свойство сохранено намеренно: ' + 'с #2731 (2026-08-06) обе колонки берут statement_timestamp(), стабильный в statement''е ' + '(clock_timestamp() развёл бы их на микросекунды и сделал бы предикат ' + '`last_seen_at > scraped_at` из миграции 161 истинным почти для всех строк). ' + 'У строк ДО перехода — время старта транзакции batch''а, см. комментарий к scraped_at.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/233_payments.sql b/tradein-mvp/backend/data/sql/233_payments.sql new file mode 100644 index 00000000..99dfeca9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/233_payments.sql @@ -0,0 +1,305 @@ +-- 233_payments.sql +-- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии +-- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»). +-- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на +-- месте по итогам review (статус HOLD), без ребейза номера. Дважды +-- переименована (git mv, история сохранена): 228 → 232 → 233. Номер 228 +-- заняли 228_scrape_proxies_browser_health.sql и 230_house_merge_log.sql +-- (влились в main); 229 и 231 занимает открытый PR #2547; 232 занял открытый +-- PR #2742 (`232_listings_observation_time_meaning.sql`). Урок: сверять номер +-- нужно не только по `forgejo/main`, но и по ВСЕМ открытым PR-веткам — ни один +-- из этих файлов сам себя в `_manifest_applied.txt` не пишет (мы пишем), из-за +-- чего коллизия обнаруживается только тестом `test_new_files_do_not_reuse_prefix` +-- уже после того, как чей-то PR смержен первым. +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- Этот PR — ТОЛЬКО схема + конфиг + kill-switch (`PAYMENTS_ENABLED=false` в +-- app/core/config.py, тот же PR). Роутера, статус-машины и обработчика +-- нотификаций здесь НЕТ (появятся в PR-D/E; PR-C — token/tbank_client/receipt — +-- уже смержен, схемы не касается). До PAYMENTS_ENABLED=true эти три таблицы +-- просто не пишутся никаким кодом; создание сейчас разблокирует параллельную +-- разработку PR-D без гонки миграций. +-- +-- ── WHAT ───────────────────────────────────────────────────────────────────── +-- payments — одна строка на попытку оплаты (Init → notify → +-- Confirm/Cancel). order_id — наш внутренний id, +-- уходит в T-Bank как OrderId (CHECK ≤50 симв. — +-- падать у себя, а не на /v2/Init); tbank_payment_id — +-- PaymentId из ответа Init, известен только ПОСЛЕ +-- вызова. pd_erased_at — см. отдельный блок ниже. +-- payment_notifications — append-only лог входящих вебхуков Т-Банка. +-- Идемпотентность нотификаций — это и есть +-- UNIQUE NULLS NOT DISTINCT(tbank_payment_id, status, +-- amount_kopecks, token): T-Bank шлёт AUTHORIZED и +-- CONFIRMED одновременно, дедуп через ON CONFLICT DO +-- NOTHING (сервисный код — PR-D). processed_at — +-- контракт fulfillment, см. блок ниже. Осознанно БЕЗ +-- CHECK на status: это сырой лог входящих данных, +-- узкий CHECK здесь означал бы, что недокументиро- +-- ванный/новый статус банка ломает запись самого +-- факта нотификации. +-- payment_entitlements — факт «что выдано за платёж» (доступ), НЕ кошелёк. +-- См. блок про amount/consumed ниже. +-- +-- ── ИДЕМПОТЕНТНОСТЬ UNIQUE-ключей: NULLS NOT DISTINCT (найдено на проде) ──── +-- Первая версия миграции использовала обычный UNIQUE на обоих ключах +-- дедупликации. В Postgres обычный UNIQUE считает NULL уникальным относительно +-- самого себя (NULL ≠ NULL) — при ref_id IS NULL / token IS NULL несколько +-- строк с одинаковым остальным набором колонок НЕ схлопываются. Это не +-- гипотетика: три одинаковых INSERT в payment_entitlements с ref_id IS NULL +-- дали три строки вместо одной при проверке на проде (до первого реального +-- применения этой миграции — воспроизведено отдельно). PG 16.4 (прод) умеет +-- `UNIQUE NULLS NOT DISTINCT` (с PG15) — NULL трактуется как равный NULL, +-- ровно то поведение, которое ожидает сервисный слой (ON CONFLICT DO NOTHING / +-- DO UPDATE). Применено к обоим дедуп-ключам ниже. +-- +-- ── СТАТУСЫ T-BANK (payments.status CHECK) ────────────────────────────────── +-- Источник истины — официальная OpenAPI-спека: +-- https://developer.tbank.ru/schemas/eacq/openapi.yaml (OpenAPI 3.0.2, v1.24), +-- схема `Confirm-2`, 24 значения. На странице /eacq/intro/developer/openapi +-- прямо сказано: при расхождении прозы и спеки приоритет у спеки — поэтому +-- ссылка на спеку, а не на человекочитаемые доки. +-- +-- ВАЖНО про GetState/CheckOrder (ручки, которыми реконсиляция PR-E читает +-- статус): в спеке их поле `Status` объявлено СВОБОДНОЙ строкой +-- (`maxLength: 20`, БЕЗ enum). То есть на ручках, которыми фактически питается +-- реконсиляция, банк словарь значений не фиксирует контрактно — наш CHECK +-- здесь строже, чем контракт поставщика. Это осознанный выбор (закрытый +-- список читается и валидируется проще, чем произвольная строка), а не +-- недосмотр; следующий читатель должен видеть, что этот CHECK может однажды +-- отвергнуть легитимный, но недокументированный `Confirm-2`-строкой статус — +-- см. контракт 'UNKNOWN' ниже. +-- +-- Сверка по `Confirm-2` относительно первой версии миграции: +-- - УБРАНЫ 'AUTHORIZED_AND_CHARGED' и 'RECEIPT_REGISTERED' — отсутствуют в +-- `Confirm-2`. Дополнительно у поля `Status` в спеке `maxLength: 20`, а +-- 'AUTHORIZED_AND_CHARGED' — 22 символа: физически не может быть значением +-- этого поля, не только "не найдено", а невозможно по контракту. +-- - ДОБАВЛЕНЫ '3DS_CHECKING' и '3DS_CHECKED' — есть в `Confirm-2`. +-- - НЕ добавлены 'ATTEMPTS_EXPIRED' и 'PAY_CHECKING' — отсутствуют в +-- `Confirm-2`, гипотеза не подтвердилась. +-- - 'PREAUTHORIZING' оставлен и подтверждён: есть в `Confirm-2`. (Ранее +-- редакция ссылалась на комментарий стороннего Go-клиента о том, что этот +-- статус будто бы убран из API — спекой это не подтверждается, комментарий +-- был неточным источником и снят.) +-- - ДОБАВЛЕНЫ пять пропущенных in-flight значений из `Confirm-2`: +-- 'CHECKING', 'CHECKED', 'PROCESSING', 'COMPLETING', 'COMPLETED'. Это +-- ровно те статусы, которые GetState/CheckOrder вернёт по зависшему +-- платежу — их читает реконсиляция (PR-E). Пропуск реального значения — +-- единственное опасное направление ошибки CHECK: не "лишний" статус +-- проскочит, а свой же CHECK отвергнет то, что банк реально прислал. +-- Порядок в списке ниже — по смысловой близости к соседним стадиям +-- (CHECKING/CHECKED рядом с 3DS_CHECKING/3DS_CHECKED, COMPLETING/COMPLETED +-- рядом с CONFIRMED), а не порядок из спеки — `Confirm-2` не гарантирует +-- порядок enum, для CHECK-констрейнта (проверка принадлежности множеству) +-- порядок значения не имеет. +-- - 'PARTIAL_REVERSED' и 'REFUND_FAILED' в `Confirm-2` ОТСУТСТВУЮТ. Оставлены +-- в CHECK как безвредный запас на случай появления в будущей версии API +-- (сам факт лишнего разрешённого значения в CHECK ничего не ломает — в +-- отличие от отсутствующего). Это отличается от предыдущей редакции +-- комментария, которая ошибочно утверждала, что сверка их "подтверждает": +-- не подтверждает, они не найдены в источнике истины. +-- +-- Контракт: если банк присылает статус вне списка ниже, ОБРАБОТЧИК +-- НОТИФИКАЦИЙ И ЗАДАЧА РЕКОНСИЛЯЦИИ (GetState/CheckOrder, PR-E) обязаны +-- писать в payments.status значение 'UNKNOWN' (не поднимать исключение, не +-- терять запись) — сырое тело нотификации в любом случае лежит целиком в +-- payment_notifications.body (у GetState/CheckOrder своего append-only лога +-- нет — если реконсиляция сама не сохранит сырой ответ, факт неизвестного +-- статуса останется только в payments.status='UNKNOWN' и её собственных логах). +-- INSERT/UPDATE payments с любым другим незнакомым значением упадёт на +-- CHECK — это специально: тихое искажение статуса хуже, чем громкий сбой +-- одной записи. +-- +-- ── payment_entitlements: без кредитно-кошельковой семантики ──────────────── +-- Первая версия несла amount/consumed (модель «кредиты/пакеты»). Явно +-- отвергнуто в `mera-b2c-paid-flow-decision.md` (§1): «Что отвергнуто явно: +-- кредиты/пакеты, роль customer, ... — цена ошибки в guard'е выше годовой +-- выручки этой воронки». Доставка купленного выбрана через capability-URL +-- (`/r/`, волна 3 §9 того же дока), а не через списание количества с +-- баланса. Таблица остаётся фактом «что выдано за платёж» (payment → kind +-- [+ ref_id]), без количественного состояния. Если модель когда-нибудь +-- реально понадобится — восстановить amount/consumed дешевле (ADD COLUMN), +-- чем сейчас снимать с них зависимости в PR-E, которого ещё нет. +-- +-- ── payments.pd_erased_at: покрытие purge-контура #2547 ───────────────────── +-- #2547 знает про PII в trade_in_leads/trade_in_estimates, но НЕ про payments +-- — эта таблица нового хранилища ПДн (customer_email/customer_phone) появится +-- вместе с PR-D. pd_erased_at NULL = ПДн не стирались; проставляется по +-- запросу субъекта на удаление — обнуляет customer_email/customer_phone, +-- фискально значимые поля (order_id, amount_kopecks, confirmed_at, +-- terminal_key и т.д.) остаются нетронутыми (обязательны для чека/сверки с +-- банком). Сам purge-job — вне scope этого PR (схема-only); колонку дешевле +-- завести сейчас, чем добавлять отдельной миграцией после того как PR-D +-- начнёт писать ПДн в эту таблицу. +-- +-- ── payment_notifications.processed_at: контракт fulfillment (для PR-D) ───── +-- Без этой колонки обработчик получается at-most-once по ОШИБКЕ: если процесс +-- упал ПОСЛЕ INSERT нотификации, но ДО выдачи товара (payment_entitlements / +-- инкремент квоты), ретрай банка увидит уже существующую строку через +-- ON CONFLICT DO NOTHING, ответит "OK" и товар не выдастся никогда — при этом +-- деньги у клиента уже списаны/захолдированы. Контракт для PR-D: обработка +-- нотификации считается завершённой (fulfillment состоялся) ТОЛЬКО когда +-- processed_at проставлен; сам факт наличия строки в payment_notifications +-- этого не гарантирует и не должен использоваться как признак «обработано». +-- +-- ── IDEMPOTENCY ────────────────────────────────────────────────────────────── +-- CREATE TABLE IF NOT EXISTS + DROP CONSTRAINT IF EXISTS перед ADD CONSTRAINT +-- (безопасный re-run на CHECK). Ничего не удаляет и не бэкфиллит. +-- +-- ── FK на trade_in_estimates / trade_in_leads ─────────────────────────────── +-- Обе таблицы проверены по факту (001_trade_in_estimates.sql, +-- 172_trade_in_leads.sql): id uuid PRIMARY KEY DEFAULT gen_random_uuid() в +-- обеих — FK безопасен, типы совпадают. ON DELETE SET NULL — по образцу +-- уже существующего trade_in_leads.estimate_id (172_trade_in_leads.sql:11): +-- обе колонки здесь опциональные бизнес-ссылки, а не владеющая связь, удаление +-- estimate/lead не должно ронять запись о платеже. +-- +-- Dependencies: 001_trade_in_estimates.sql, 172_trade_in_leads.sql. +-- Apply after: 230_house_merge_log.sql. + +BEGIN; + +-- ───────────────────────────────────────────────────────────────────────── +-- payments +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + order_id text NOT NULL UNIQUE CHECK (char_length(order_id) <= 50), + tbank_payment_id text UNIQUE, -- PaymentId из ответа Init (NULL до Init) + terminal_key text NOT NULL, + product_code text NOT NULL, -- что продали (product_code, не цена из тела запроса) + amount_kopecks bigint NOT NULL CHECK (amount_kopecks > 0), + currency text NOT NULL DEFAULT 'RUB', + status text NOT NULL DEFAULT 'NEW', + payment_url text, + + created_by text, -- username (X-Authenticated-User), NULL если анонимный checkout + estimate_id uuid REFERENCES trade_in_estimates(id) ON DELETE SET NULL, + lead_id uuid REFERENCES trade_in_leads(id) ON DELETE SET NULL, + customer_email text, + customer_phone text, + pd_erased_at timestamptz, -- см. блок про purge-контур #2547 в шапке файла + + error_code text, + error_message text, + init_response jsonb, -- сырой ответ T-Bank /v2/Init, для дебага + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + authorized_at timestamptz, + confirmed_at timestamptz, + refunded_at timestamptz +); + +ALTER TABLE payments DROP CONSTRAINT IF EXISTS payments_status_check; +ALTER TABLE payments + ADD CONSTRAINT payments_status_check + CHECK (status IN ( + 'NEW', + 'FORM_SHOWED', + 'DEADLINE_EXPIRED', + 'CANCELED', + 'PREAUTHORIZING', + 'AUTHORIZING', + 'AUTHORIZED', + 'AUTH_FAIL', + 'REJECTED', + '3DS_CHECKING', + '3DS_CHECKED', + 'CHECKING', + 'CHECKED', + 'PROCESSING', + 'CONFIRMING', + 'CONFIRMED', + 'COMPLETING', + 'COMPLETED', + 'REVERSING', + 'PARTIAL_REVERSED', + 'REVERSED', + 'REFUNDING', + 'PARTIAL_REFUNDED', + 'REFUNDED', + 'REFUND_FAILED', + 'UNKNOWN' + )); + +CREATE INDEX IF NOT EXISTS payments_status_created_idx ON payments (status, created_at); +CREATE INDEX IF NOT EXISTS payments_created_by_idx ON payments (created_by); +CREATE INDEX IF NOT EXISTS payments_estimate_idx ON payments (estimate_id); +-- lead_id имеет FK ON DELETE SET NULL — без индекса Postgres делает seq scan +-- по payments на каждый DELETE FROM trade_in_leads (проверка "нет ли ссылок" +-- перед SET NULL). #2547 вводит пакетное физическое удаление лидов — без +-- индекса это N seq scan'ов по payments на один batch-прогон purge-джобы. +CREATE INDEX IF NOT EXISTS payments_lead_idx ON payments (lead_id); + +COMMENT ON TABLE payments IS + 'Платёжный контур МЕРЫ (Т-Банк эквайринг). Одна строка на попытку оплаты. ' + 'Контур выключен по умолчанию — см. PAYMENTS_ENABLED в app/core/config.py.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- payment_notifications — append-only, идемпотентность входящих вебхуков +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payment_notifications ( + id bigserial PRIMARY KEY, + + order_id text, + tbank_payment_id text, + status text, -- сырой статус из тела, без CHECK (см. WHY выше) + amount_kopecks bigint, + token text, + token_valid boolean NOT NULL, + body jsonb NOT NULL, -- полное тело нотификации как есть + + received_at timestamptz NOT NULL DEFAULT now(), + -- Контракт fulfillment для PR-D — см. подробный блок в шапке файла. + -- NULL = обработка (выдача товара) ещё не завершена или не начиналась; + -- проставляется сервисным кодом ПОСЛЕ успешной выдачи, не в момент INSERT. + processed_at timestamptz, + + -- Дедуп-ключ идемпотентности (recon §3 п.4): T-Bank шлёт AUTHORIZED и + -- CONFIRMED одновременно для одностадийной оплаты; ON CONFLICT DO NOTHING + -- в сервисном коде (PR-D) значит "уже обработано". NULLS NOT DISTINCT + -- (см. блок в шапке файла) — без него NULL в token/tbank_payment_id не + -- считался бы дублем самого себя, и дедуп молча переставал бы работать + -- ровно в вырожденном случае, для которого он и нужен. + UNIQUE NULLS NOT DISTINCT (tbank_payment_id, status, amount_kopecks, token) +); + +COMMENT ON TABLE payment_notifications IS + 'Append-only лог входящих вебхуков T-Bank. Идемпотентность через UNIQUE ' + 'NULLS NOT DISTINCT(tbank_payment_id, status, amount_kopecks, token) + ' + 'ON CONFLICT DO NOTHING. processed_at — контракт "выдача состоялась" для PR-D.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- payment_entitlements — что выдано за платёж (факт, не кошелёк) +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payment_entitlements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + payment_id uuid NOT NULL REFERENCES payments(id), + subject text NOT NULL, -- username или anon-token, кому выдано + kind text NOT NULL, -- 'pdf_report' | 'report_link' | ... + ref_id uuid, -- estimate_id для разового отчёта, NULL если не применимо + + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + + -- Гарантия "выдали один раз" (recon §3). NULLS NOT DISTINCT (см. блок в + -- шапке файла) — без него при ref_id IS NULL несколько строк с одинаковым + -- (payment_id, kind) НЕ считались бы дублем этим UNIQUE, что и + -- воспроизвелось на проде до первого применения миграции. + UNIQUE NULLS NOT DISTINCT (payment_id, kind, ref_id) +); + +COMMENT ON TABLE payment_entitlements IS + 'Факт "что выдано за платёж" (доступ), НЕ кредитный кошелёк — amount/' + 'consumed сознательно отсутствуют, см. mera-b2c-paid-flow-decision.md §1 ' + '(модель кредитов/пакетов отвергнута явно). UNIQUE NULLS NOT DISTINCT ' + '(payment_id, kind, ref_id) страхует fulfillment (PR-E) от повторной ' + 'выдачи по одной нотификации.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql b/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql new file mode 100644 index 00000000..ecaead7e --- /dev/null +++ b/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql @@ -0,0 +1,55 @@ +-- 234_scrape_runs_ban_kind_unknown.sql +-- scrape_runs.ban_kind получает третье значение 'unknown' — «причина не установлена» (#2764). +-- +-- Dependencies: 218_scrape_runs_ban_kind.sql (создала колонку и CHECK по ban_kind). +-- Apply after: 233_payments.sql +-- Идемпотентно: DROP/ADD CONSTRAINT + UPDATE, который после первого прогона +-- никого не находит (условие ban_kind='platform' перестаёт выполняться). +-- +-- ── ЗАЧЕМ ──────────────────────────────────────────────────────────────────── +-- 218 дала статусу 'banned' диагноз, но дефолт в сигнатуре mark_banned остался +-- 'platform', а передавали его 3 вызывающих из 13. Замер на проде 2026-08-07: +-- SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1; +-- infra | 92 ← ретро-классификация 218 по тексту (разовая, осмысленная) +-- platform | 41 ← в т.ч. 2 строки, написанные УЖЕ в рантайме, по умолчанию +-- Обе рантайм-строки пришли из общего финализатора backfill-задач +-- (scrape_runs.mark_backfill_finished, #2674), который диагноз не передаёт вовсе: +-- 3306 avito_detail_backfill 2026-08-06 12:42 blocked=5, 0 обогащено из 5 +-- 3313 domclick_detail_backfill 2026-08-06 15:20 blocked=3, 0 обогащено из 3 +-- Причина отказов ни у одной из них не устанавливалась. Метка 'platform' у них — +-- не вывод, а значение по умолчанию, и выглядит она ровно как доказанные 10 +-- строк «Avito SERP firewall». Оставить их — значит сохранить ложное +-- доказательство ровно того класса, ради которого 218 и делалась. +-- +-- ── ГРАНИЦА ПРАВКИ ─────────────────────────────────────────────────────────── +-- Ретро-классификацию 218 (по тексту ошибки, применена 2026-08-06 08:24:19 UTC) +-- НЕ трогаем: разбор текста был там законным — типов исключений у исторических +-- строк уже нет, а маркер сайдкара в тексте есть. Трогаем только строки, которые +-- та миграция УЖЕ НЕ ВИДЕЛА (finished_at позже её применения) и которые пришли из +-- финализатора backfill'ов — единственного вызывающего, который на тот момент +-- писал ban_kind по умолчанию. Условие по error здесь опознаёт ВЫЗЫВАЮЩЕГО +-- (префикс, который он сам ставит), а не причину отказа: диагноз из текста не +-- выводится ни здесь, ни тем более в рантайме. +-- +-- Верхней границы по времени нет намеренно: миграции применяются деплоем ДО +-- рестарта приложения, но если между этим и новым кодом финализатор успеет +-- пометить ещё строки — они тоже написаны дефолтом и тоже подлежат переводу. + +ALTER TABLE scrape_runs DROP CONSTRAINT IF EXISTS scrape_runs_ban_kind_check; +ALTER TABLE scrape_runs + ADD CONSTRAINT scrape_runs_ban_kind_check + CHECK (ban_kind IS NULL OR ban_kind IN ('platform', 'infra', 'unknown')); + +COMMENT ON COLUMN scrape_runs.ban_kind IS + 'Диагноз status=''banned'' (#2686, #2764): platform — площадка заблокировала ' + '(firewall/403/captcha, доказано типом исключения); infra — не отдала НАША ' + 'инфраструктура (браузерный сайдкар/прокси); unknown — причина НЕ установлена ' + '(дефолт mark_banned). NULL для прогонов с другим статусом. Пишется из типа ' + 'исключения в момент отказа, не из текста ошибки.'; + +UPDATE scrape_runs +SET ban_kind = 'unknown' +WHERE status = 'banned' + AND ban_kind = 'platform' + AND finished_at > TIMESTAMPTZ '2026-08-06 08:24:19.102335+00' -- 218 applied_at + AND error LIKE 'backfill-honest-status:%'; diff --git a/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql b/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql new file mode 100644 index 00000000..32220153 --- /dev/null +++ b/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql @@ -0,0 +1,90 @@ +-- 238_listings_ceiling_height_unify.sql +-- Purpose (#2699): свести высоту потолков к ОДНОЙ колонке — listings.ceiling_height_m. +-- +-- Dependencies: 019_listings_alter_cian.sql (ceiling_height numeric(3,2)), +-- 111_listings_avito_detail_fields.sql (ceiling_height_m numeric(5,2)). +-- Apply after: 234_scrape_runs_ban_kind_unknown.sql +-- Идемпотентно: UPDATE'ы по предикату «уже приведено» + COMMENT ON COLUMN. +-- +-- ── ЧТО БЫЛО ───────────────────────────────────────────────────────────────── +-- Две колонки одного смысла, источники разложены по ним НЕ ПЕРЕСЕКАЯСЬ, а +-- потребитель (эстиматор, estimator._apply_kitchen_ceiling_signal) читает одну. +-- +-- Прод-замер 2026-08-07, listings (всего 94 373): +-- source | всего | ceiling_height | ceiling_height_m | обе | расходятся +-- avito | 48 592 | 0 | 7 149 | 0 | 0 +-- cian | 21 951 | 855 | 0 | 0 | 0 +-- yandex | 16 854 | 7 699 | 7 675 | 7 675 | 0 +-- domklik | 6 594 | 0 | 0 | 0 | 0 +-- n1 | 382 | 0 | 0 | 0 | 0 +-- +-- РАСХОЖДЕНИЙ НЕТ: там, где заполнены обе (7 675 строк), значения СОВПАДАЮТ до +-- последнего знака. Значит это не «две разные правды», а одна правда в двух +-- ящиках — задача не про неверную цену, а про лишнюю колонку и потерянное +-- покрытие: 855 циановских + 24 яндексовых значения не видит ни один потребитель. +-- +-- ── ВЫБОР КАНОНА: ceiling_height_m ────────────────────────────────────────── +-- 1. Единицы в имени. Так этот же признак назван ВЕЗДЕ в проекте: +-- domrf_kn_flats.ceiling_height_m, domrf_kn_objects.ceiling_height_m, +-- фронтовый ObjectSpecsTable. listings.ceiling_height — единственное исключение. +-- 2. Её читает единственный потребитель, влияющий на цену (эстиматор). +-- 3. numeric(5,2) против numeric(3,2): у 019-колонки потолок 9.99, и запись +-- out-of-range роняет ВЕСЬ батч DataError'ом (per-lot SAVEPOINT ловит только +-- IntegrityError) — это уже приходилось обходить инлайн-гейтом в yandex SERP. +-- 4. После переноса она — надмножество: 15 591 против 8 554 (посчитано +-- симуляцией на проде: 14 824 − 110 мусорных + 877 перенесённых). +-- +-- ── ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ ───────────────────────────────────────────────── +-- 1. Чистит невозможные значения в ОБЕИХ колонках (вне 2.0–6.0 м). +-- 2. Переносит уникальные значения ceiling_height → ceiling_height_m. +-- 3. Помечает ceiling_height как DEPRECATED (писателей у неё больше нет). +-- DROP COLUMN намеренно НЕ здесь: сначала прод должен подтвердить, что колонку +-- никто не пишет и не читает. Снос — отдельным шагом, за database-expert. + +BEGIN; + +-- ── 1. Невозможные значения → NULL ────────────────────────────────────────── +-- ceiling_height_m: 26 строк > 6 м (максимум 29.90) + 84 строки < 2 м (83 из них +-- ровно 0.00) — все из avito detail, где парсер брал первое число строки без +-- проверки. Корень починен в scraper_kit.ceiling_height.plausible_ceiling_m; +-- здесь — исторический хвост. NULL, а не clamp: 0.00 и 29.90 это не потолок, а +-- мис-парс, и «починить» их в правдоподобное число мы не можем — сырой текст +-- остаётся в raw_payload / на карточке. +UPDATE listings + SET ceiling_height_m = NULL + WHERE ceiling_height_m IS NOT NULL + AND ceiling_height_m NOT BETWEEN 2.0 AND 6.0; + +-- ceiling_height: 2 строки cian (0.00 и 1.70). Чистим до переноса, чтобы шаг 2 +-- не затащил мусор в канон. +UPDATE listings + SET ceiling_height = NULL + WHERE ceiling_height IS NOT NULL + AND ceiling_height NOT BETWEEN 2.0 AND 6.0; + +-- ── 2. Перенос уникальных значений в канон ────────────────────────────────── +-- Ожидаемо 877 строк: 855 cian (писал только в 019-колонку) − 2 мусорных, +-- отсеянных шагом 1, + 24 yandex (detail записал, а SERP по этой карточке +-- значения не дал). Пересечение переносить нечего — значения там тождественны +-- (см. замер выше), поэтому только IS NULL. +-- Итог после миграции (симуляция на проде 2026-08-07): ceiling_height_m = 15 591 +-- непустых, из них 0 вне диапазона 2.0–6.0. +UPDATE listings + SET ceiling_height_m = ceiling_height + WHERE ceiling_height_m IS NULL + AND ceiling_height IS NOT NULL; + +-- ── 3. Пометка устаревшей колонки ─────────────────────────────────────────── +COMMENT ON COLUMN listings.ceiling_height IS + 'DEPRECATED (#2699, 2026-08-07). Канон — listings.ceiling_height_m. ' + 'Писателей нет: base.save_listings / cian_detail / yandex_detail переведены ' + 'на ceiling_height_m, coverage-дашборд строку убрал. Значения перенесены этой ' + 'миграцией. Оставлена временно как страховка отката; снос — отдельным шагом ' + 'после прод-подтверждения, что никто не пишет и не читает.'; + +COMMENT ON COLUMN listings.ceiling_height_m IS + 'Высота потолков, метры. КАНОН (#2699) — единственная колонка этого признака. ' + 'Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m ' + '(гейт правдоподобия 2.0–6.0 м). Читает estimator (comp-scoring #2012).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/239_scrape_schedules_seed_house_coords_from_listings.sql b/tradein-mvp/backend/data/sql/239_scrape_schedules_seed_house_coords_from_listings.sql new file mode 100644 index 00000000..55192b3e --- /dev/null +++ b/tradein-mvp/backend/data/sql/239_scrape_schedules_seed_house_coords_from_listings.sql @@ -0,0 +1,72 @@ +-- 239_scrape_schedules_seed_house_coords_from_listings.sql +-- Ежедневный перенос координат из объявлений в дом, когда объявления согласны (#2771). +-- +-- ЧТО (source='house_coords_from_listings'): +-- product_handlers._job_house_coords_from_listings → run_house_coords_from_listings +-- (app/tasks/backfill_house_coords_from_listings.py). Для каждого дома с lat IS NULL: +-- центроид geom его объявлений + разброс (max ST_DistanceSphere до центроида). +-- Разброс <= max_spread_m → центроид пишется в дом; больше → дом не трогается и +-- попадает в отдельный счётчик skipped_scattered. +-- +-- ЗАЧЕМ ПОВТОРЯЮЩАЯСЯ, а не однократный скрипт: +-- Дом берёт координаты один раз при создании, из породившего его объявления. Объявление +-- геокодируется ПОЗЖЕ (geoportal_coords_backfill, geocode_missing_listings) и пишет в +-- listings; ON CONFLICT у вставки дома обновляет только address. Обратно координаты не +-- возвращались — на проде 2026-08-07 накопилось 1945 домов из 9625 (20.2%) без координат, +-- 1490 из них уже имеют объявления С координатами. Приток новых таких домов не +-- прекращается, поэтому однократный прогон закрыл бы дыру на день. +-- +-- ПОРОГ 125 м: половина 250-метрового гео-ограждения house_dedup_merge (#2187). Записанный +-- центроид — оценка с погрешностью ровно в разброс; отдать ей весь бюджет ограждения +-- значит позволить двум домам разъехаться на 500 м и сломать сравнение. Гистограмма +-- разброса обрыва не даёт (ядро 0-125 м, затем ровное плато до 400 м), поэтому порог +-- взят от потребителя, а не подогнан к данным. Цена: 125 м пропускает 1382 дома, +-- 250 м — 1403; 21 дом разницы не теряется, а откладывается до следующего прогона. +-- +-- ОКНО 03:00-04:00 UTC, ежедневно (interval_days по умолчанию = 1): +-- ПЕРЕД house_dedup_merge (04:00-05:00, migration 135) — недельный дедуп застаёт +-- координаты, перенесённые этой ночью из объявлений, геокодированных накануне +-- (geoportal_coords_backfill 05:00-06:00, geocode_missing_listings 06:00-09:00). +-- Чисто внутренняя операция БД: ни HTTP, ни прокси, ни anti-bot — конкуренции нет. +-- +-- enabled=true: операция НЕ деструктивная — пишет только туда, где lat IS NULL, и не +-- перезаписывает ни одной существующей координаты (тот же приём, что у +-- geoportal_coords_backfill, migration 171, тоже засеянного enabled=true). +-- +-- default_params: +-- max_spread_m -- 125: предел разброса объявлений в метрах (см. выше). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно применять повторно. +-- Runner применяет миграции БЕЗ --single-transaction, поэтому явные BEGIN/COMMIT. + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'house_coords_from_listings', + true, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"max_spread_m": 125}'::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. Последний добавленный: ' + 'house_coords_from_listings (#2771: ежедневный перенос координат из объявлений в дом ' + 'при согласии объявлений, разброс <= 125 м; дома с большим разбросом пропускаются ' + 'и считаются отдельно — это дефект сопоставителя #2777, не переноса).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql new file mode 100644 index 00000000..e9b0cb67 --- /dev/null +++ b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql @@ -0,0 +1,90 @@ +-- 240_trade_in_estimates_retain_until.sql +-- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge +-- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен +-- жить год на нашей стороне, а не 24ч (см. WHY ниже). +-- Номер сверен по `forgejo/main` и всем открытым PR-веткам ДВАЖДЫ: сначала +-- как 234 (последняя занятая на момент ветвления была 233_payments.sql), но +-- main уехал вперёд и 234 занял `234_scrape_runs_ban_kind_unknown.sql` +-- (коммит 0de22f4b) — переименовано в 240 (main max на момент повторной +-- сверки — 239, с дырами 235-237; max+1 безопаснее дыр). Урок пятый за +-- сутки: сверять номер нужно не только перед первым коммитом, а прямо перед +-- пушем/мержем — main не стоит на месте. +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет +-- строки `WHERE expires_at < NOW() AND created_by IS NULL` — это ровно +-- популяция будущих платящих физлиц (анонимные B2C-оценки). Владелец продаёт +-- отчёт физлицу за 150 ₽: скачанный файл у клиента бессрочно, но ссылка/строка +-- на нашей стороне обязана жить дольше 24-часового TTL расчёта — иначе первый +-- же прогон purge-джобы после запуска продаж физически и безвозвратно удалит +-- уже оплаченное (PDF нигде не хранится, рендерится на лету). +-- +-- `expires_at` НЕ трогаем ни на йоту: это единая глобальная настройка +-- (`trade_in_estimate_retention_hours`), она же — печатаемая в PDF/UI дата +-- «ДЕЙСТВИТЕЛЕН ДО» (актуальность РАСЧЁТА, а не срок жизни строки), и от неё +-- зависит вычисление даты расчёта во фронте (`mappers.ts` fmtDateShift(-24)). +-- Поднять её до года означало бы: (а) дать год хранения ВСЕМ строкам, включая +-- неоплаченные адреса физлиц — прямое нарушение минимизации по 152-ФЗ; +-- (б) напечатать в PDF клиента, что расчёт актуален год. +-- +-- ── WHAT ───────────────────────────────────────────────────────────────────── +-- Новая, независимая колонка retain_until — срок жизни ДОСТУПА/СТРОКИ: +-- NULL = неоплаченная строка, поведение (чтение/PDF/purge) бит-в-бит текущее. +-- Бэкфилла нет — все 1058 существующих строк остаются NULL, ничего не меняется +-- для уже созданных оценок (весь B2B pilot-трафик в их числе). +-- При оплате (платёжный код — отдельный PR, здесь его нет) сервисный слой +-- проставит retain_until = now() + trade_in_paid_retention_days (config.py). +-- +-- Частичный индекс покрывает predicate purge-джобы (migration 231, +-- `_DELETE_EXPIRED_ESTIMATES_SQL`) уже С УЧЁТОМ нового терма retain_until — +-- заведён вместе с колонкой, а не отдельной миграцией, чтобы purge не начал +-- жить без него хотя бы один деплой. +-- +-- ── IDEMPOTENCY ────────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- Ничего не удаляет, не бэкфиллит, DDL-only (доли секунды на 1058 строках). +-- +-- Dependencies: 001_trade_in_estimates.sql, 233_payments.sql (индекс исключает +-- строки со строкой в payments опосредованно через predicate purge-джобы, +-- сама таблица payments здесь не читается). +-- Apply after: 233_payments.sql. +-- +-- ── lock_timeout — выставлен, хотя гейт (scripts/check-migration-lock-timeout.py) +-- этот файл не проверяет ──────────────────────────────────────────────────── +-- Порог гейта для tradein (NN >= 250) — артефакт: назначен по номеру аварийной +-- миграции 250, которую затем сняли с деплоя (#2792, 29f10002). Фактический +-- максимум применённого на main — 239, то есть НИ ОДНА миграция в диапазоне +-- 240-249 (этот файл включительно) гейтом не проверяется вообще — "проверено +-- новых миграций: 0" в логе означает "не проверено ни одного файла", а не +-- "все чисты". Сама функция scan() внутри гейта, если прогнать её без +-- порогового отсечения, помечает ALTER TABLE ниже как блокирующий DDL без +-- lock_timeout. `trade_in_estimates` — самая горячая таблица стека (история, +-- история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались +-- открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка +-- встаёт в очередь ПЕРЕД новыми запросами — за ней начинают ждать обычные +-- SELECT приложения (см. `sql.md` § lock_timeout). На 1058 строках сам DDL +-- мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Красный +-- деплой по таймауту — осознанно принятый в проекте размен (честный отказ +-- лучше тихой очереди перед приложением). НЕ убирать как "гейт же не просит". + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS retain_until timestamptz; + +COMMENT ON COLUMN trade_in_estimates.retain_until IS + 'До какого момента строку НЕЛЬЗЯ удалять и ссылка обязана открываться ' + '(оплаченный доступ). Семантика expires_at не меняется: это дата ' + 'актуальности РАСЧЁТА (24ч), она печатается в PDF. NULL = неоплачено, ' + 'поведение бит-в-бит текущее. Задаётся сервисным кодом платёжного контура ' + '(отдельный PR) на now() + trade_in_paid_retention_days (config.py).'; + +-- Частичный индекс под predicate purge-джобы (app/tasks/purge_expired_trade_in_data.py): +-- WHERE created_by IS NULL AND retain_until IS NULL AND expires_at < NOW(). +CREATE INDEX IF NOT EXISTS trade_in_estimates_purge_idx + ON trade_in_estimates (expires_at) + WHERE created_by IS NULL AND retain_until IS NULL; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql new file mode 100644 index 00000000..d725963d --- /dev/null +++ b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql @@ -0,0 +1,140 @@ +-- 250_drop_duplicate_expires_at_index.sql +-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at). +-- Возврат после #2792 (снятие с деплоя) — теперь с lock_timeout, см. #2793/#2791. +-- +-- WHY: +-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09) +-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль +-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql. +-- +-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени): +-- name indkey indclass indoption indcollation pred am +-- trade_in_estimates_expires_idx 22 3127 0 0 — btree +-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree +-- Совпадает всё: колонка, класс операторов, направление сортировки, +-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация, +-- отсутствие частичного предиката, метод доступа. Ни один не привязан к +-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них +-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует +-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты +-- FDW-пользователю). Гранты живут на таблице, не на индексе. +-- +-- ── Почему у «нулевого» дубля появились сканы ──────────────────────────────── +-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у +-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый +-- +0, новый +4). Замер 2026-08-09 16:54 UTC подтверждает картину ещё через +-- двое суток: новый 21, старый ВСЁ ЕЩЁ 234. То есть планировщик перевёл на +-- новый весь живой трафик, и это устойчивое состояние, а не переходное. +-- +-- Причина не семантическая, а физическая: индексы идентичны, но новый +-- собран позже с нуля и плотнее упакован — relpages 5 против 6 у старого, +-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск +-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost, +-- и при прочих равных выигрывает. Никакого нового запроса не появилось: +-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого, +-- 0.95 у нового) — это один и тот же класс точечных lookup'ов, просто +-- переехавший на более свежий индекс. Со временем новый забронзовеет так же +-- и они поменялись бы местами обратно. +-- +-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ──────────────────────────── +-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу +-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE +-- делал бы full scan». На проде это НЕ так. Фактический план боевого +-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод +-- 2026-08-07, перепроверено 2026-08-09 — план тот же): +-- Limit → Sort (Sort Key: expires_at) +-- → Bitmap Heap Scan Filter: (expires_at < now()) +-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at +-- Index Cond: (created_by IS NULL) +-- Задача purge ограничена `AND created_by IS NULL` (134 строки из 1061), и +-- планировщик берёт именно этот, более селективный индекс, а expires_at +-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не +-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён +-- под конкретный запрос» не существует — запрос его не использует. +-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму +-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с +-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД. +-- +-- ── Планы ДО и ПОСЛЕ ───────────────────────────────────────────────────────── +-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе: +-- меняется только имя индекса в строке плана и cost на одну страницу спуска. +-- ДО (прод, 2026-08-09 16:54 UTC): +-- Limit (cost=0.28..58.98 rows=100 width=24) +-- → Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..623.07) +-- Index Cond: (expires_at < now()) +-- ПОСЛЕ ожидается тот же узел с именем trade_in_estimates_expires_idx и +-- cost, отличающимся на спуск по одной лишней странице. Проверено на чистом +-- PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым +-- перекосом плотности: +-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84) +-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30) +-- Форма плана, Index Cond и Filter идентичны; отличается только имя. +-- +-- ── Стоимость блокировки и почему здесь SET LOCAL lock_timeout ────────────── +-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. УДЕРЖАНИЕ здесь +-- дёшево: trade_in_estimates — 1061 строка, heap 1856 kB, сносимый индекс +-- 40 kB; DROP INDEX ничего не переписывает (удаление строк каталога плюс +-- unlink файла, единицы миллисекунд). +-- +-- Дорого — ОЖИДАНИЕ выдачи лока, и это уже случилось. 2026-08-07 первая +-- редакция этого файла (без строки ниже) ждала ACCESS EXCLUSIVE 29 минут за +-- чужой аналитической psql-сессией (`CREATE TEMP TABLE tmp_res AS ...`, +-- pid 83256), вторая попытка — ещё 16. Четыре прогона деплоя красные, +-- четыре смерженных PR не доехали до прода; ждущий ACCESS EXCLUSIVE встаёт +-- в очередь ПЕРЕД новыми запросами, поэтому за ним начали ждать и обычные +-- SELECT приложения. Файл сняли с деплоя (#2792), конвенцию закрепили +-- (#2791: гейт scripts/check-migration-lock-timeout.py + .claude/rules/sql.md). +-- +-- Значение 5 s: снизу ограничено deadlock_timeout (на проде 1 s — сверено +-- 2026-08-09) — автоотмена мешающего autovacuum срабатывает только после +-- того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным +-- autovacuum. Сверху — потолок простоя очереди приложения; против +-- наблюдённых 1740 s это в 348 раз меньше. На работу ПОД локом значение не +-- влияет вообще. +-- +-- Срабатывание таймаута = красный деплой через 5 секунд с `canceling +-- statement due to lock timeout` вместо получасовой очереди. Это ожидаемое +-- поведение, а не авария: миграция не помечается применённой, повторить +-- позже. CONCURRENTLY здесь не нужен и был бы хуже: он не может выполняться +-- внутри блока транзакции, а значит файл пришлось бы оставить без +-- BEGIN/COMMIT (см. разбор механики раннера в +-- 225_listing_source_snapshots_run_id_idx.sql). +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE. +-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет. +-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы +-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует). +-- +-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс), +-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль). +-- Deploy order: standalone. Ничего не ждёт и никого не блокирует. +-- +-- Критерий «таблица тиха» (записан ДО, выполнен 2026-08-09 16:54 UTC): +-- SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation +-- WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid(); → 0 +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. EXPLAIN того же запроса показывает Index Scan using +-- trade_in_estimates_expires_idx — детерминированная проверка, доступна +-- сразу. +-- 3. pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx уходит с +-- 234. NB: наблюдаемый темп ~7 сканов/сутки (21 скан за трое суток у +-- дубля), поэтому «в течение часа» — недостаточное окно; честный срок +-- подтверждения ~сутки. Если через сутки счётчик всё ещё 234, значит +-- трафик ушёл в Seq Scan — это опровергло бы разбор выше и требовало бы +-- отката (вернуть индекс: CREATE INDEX CONCURRENTLY). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; + +COMMENT ON INDEX trade_in_estimates_expires_idx IS + 'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752/#2793). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql b/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql new file mode 100644 index 00000000..df6fcdb1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql @@ -0,0 +1,109 @@ +-- 251_listings_drop_ceiling_height.sql +-- Issue #2699 (хвост) — снос DEPRECATED-колонки listings.ceiling_height. +-- +-- Dependencies: 019_listings_alter_cian.sql (завела колонку, numeric(3,2)), +-- 238_listings_ceiling_height_unify.sql (перенесла значения в +-- канон ceiling_height_m и пометила эту колонку DEPRECATED). +-- Apply after: 240_trade_in_estimates_retain_until.sql +-- Deploy order: код УЖЕ впереди схемы — писатели сняты PR #2779 (07.08) и с тех +-- пор на проде. Это тот случай, когда «код первый» правилен: DROP COLUMN +-- безопасен только после того, как ни один живой writer/reader колонки не +-- остался. Обратный порядок (снести колонку, потом деплоить код) уронил бы +-- скрейпинг. +-- +-- ── ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ ФАЙЛ, А НЕ ЧАСТЬ 238 ─────────────────────────────── +-- 238 намеренно оставила колонку: «сначала прод должен подтвердить, что колонку +-- никто не пишет и не читает. Снос — отдельным шагом». Подтверждение получено, +-- ниже — числа. +-- +-- ── КРИТЕРИЙ «ПИСАТЕЛЕЙ НЕТ» (записан ДО, а не после) ─────────────────────── +-- count(ceiling_height) обязан остаться 8552 (8554 из #2699 минус 2 мусорных +-- значения cian, обнулённых шагом 1 миграции 238). +-- 2026-08-07 (сразу после 238): 8552 +-- 2026-08-09 16:54 UTC: 8552 +-- 2026-08-09 16:57 UTC: 8552 +-- Двое суток без единой записи. Контроль того, что замер не «мёртвый» (БД жива, +-- скрейпинг идёт, просто пишет в канон): за те же 2.5 минуты между двумя +-- замерами count(ceiling_height_m) вырос 16133 → 16147, а last_seen_at строк с +-- непустым ceiling_height обновлялся в ту же минуту, что и замер. То есть UPDATE +-- по этим строкам идут прямо сейчас и НЕ трогают сносимую колонку — это сильнее, +-- чем «два дня тишины». +-- +-- ── ПОТРЕБИТЕЛИ: сверка на origin/main перед сносом ───────────────────────── +-- `git grep -n 'ceiling_height\b' origin/main -- '*.py' '*.ts' '*.tsx' '*.sql'` +-- минус вхождения ceiling_height_m: ни одного обращения к КОЛОНКЕ не осталось. +-- Что попало в выдачу и почему это не потребители: +-- - имена полей Python-датаклассов enrichment'ов (CianEnrichment.ceiling_height, +-- YandexEnrichment.ceiling_height, YandexValuation...) — атрибуты объектов, +-- не колонки; +-- - `CAST(:ceiling_height AS numeric)` в yandex/detail.py:600 — ИМЯ БИНД- +-- ПАРАМЕТРА, а присваивается он колонке ceiling_height_m (соседняя строка); +-- то же в cian/detail.py (`:ch`) и base.py (`:ceiling_height_m`); +-- - комментарии/докстринги с историей #2699 и тесты, которые как раз +-- УТВЕРЖДАЮТ отсутствие колонки в SQL писателей +-- (tests/test_ceiling_height_unify_2699.py, test_scraper_admin_apis.py); +-- - 019/238 — сами миграции, их переписывать нельзя и не нужно. +-- Фронтовых (.ts/.tsx) вхождений нет вообще. +-- +-- ── ЗАВИСИМОСТИ В СХЕМЕ: проверено на проде 2026-08-09, все нули ──────────── +-- pg_depend по атрибуту listings.ceiling_height ................ 0 объектов +-- вьюхи/матвьюхи с 'ceiling' в определении ..................... 0 +-- индексы listings с 'ceiling' в indexdef ...................... 0 +-- CHECK/constraint с 'ceiling' ................................. 0 +-- функции и процедуры с 'ceiling_height' в теле ................ 0 +-- тела обоих триггеров listings (price_change, set_geom) ....... не упоминают +-- pg_publication_rel по listings (column list ломает DROP) ..... 0 (публикаций в БД нет) +-- foreign tables НА listings в gendesign-БД (FDW-читатель) ..... 0 +-- То есть CASCADE не нужен — и не должен появиться: в этом продукте +-- `DROP ... CASCADE` уже терял гранты FDW-пользователю (инцидент C3). +-- +-- ── ПОТЕРИ ДАННЫХ НЕТ (проверено, а не предположено) ──────────────────────── +-- строк, где ceiling_height IS NOT NULL AND ceiling_height_m IS NULL ..... 0 +-- строк, где заполнены обе ............................................ 8552 +-- из них расходятся значения .............................................. 0 +-- Всё содержимое сносимой колонки присутствует в каноне до последнего знака. +-- ceiling_height_m на момент написания: 16 147 непустых (после 238 было 15 591 — +-- канон растёт, то есть живой). +-- +-- ── СТОИМОСТЬ БЛОКИРОВКИ И ПОЧЕМУ SET LOCAL lock_timeout ─────────────────── +-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на listings. УДЕРЖАНИЕ +-- дёшево и не зависит от размера таблицы: PostgreSQL не переписывает heap, а +-- помечает атрибут attisdropped в каталоге (в listings уже 4 таких «пенька» от +-- прошлых сносов при 92 живых колонках) — единицы миллисекунд. +-- +-- Дорого ОЖИДАНИЕ выдачи лока, и цена здесь выше, чем у 250: listings — 19 GB, +-- 97 540 строк, по ней постоянно идёт скрейпинг (в т.ч. длинные проходы вроде +-- avito_full_load). Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми +-- запросами, поэтому за ним начнут ждать обычные SELECT/UPDATE приложения — +-- ровно то, что 2026-08-07 положило деплой на 29 минут (#2791, #2792). +-- Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено deadlock_timeout = +-- 1 s на проде, сверху — потолок простоя очереди приложения; на работу ПОД +-- локом не влияет). Срабатывание = честный красный деплой через 5 секунд, +-- миграция не помечается применённой, повторить в окно потише. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP COLUMN IF EXISTS — безопасный re-run. +-- - Без CASCADE: зависимых объектов нет (см. выше), а CASCADE молча снёс бы +-- то, что появится позже. +-- - Одна DDL-операция внутри BEGIN/COMMIT. +-- - Откат: колонку вернуть можно (ALTER TABLE ... ADD COLUMN), но данные в неё +-- не восстановятся — они и не нужны, дубликат канона (0 расхождений). +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. information_schema.columns по listings: ceiling_height отсутствует, +-- ceiling_height_m на месте и count(ceiling_height_m) >= 16 147. +-- 3. Скрейпинг продолжает писать: count(ceiling_height_m) растёт после сноса. + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE listings DROP COLUMN IF EXISTS ceiling_height; + +COMMENT ON COLUMN listings.ceiling_height_m IS + 'Высота потолков, метры. ЕДИНСТВЕННАЯ колонка этого признака (#2699): дубль listings.ceiling_height (019) снесён миграцией 251 после того, как 238 перенесла в неё значения. Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m (гейт правдоподобия 2.0-6.0 м). Читает estimator (comp-scoring #2012).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql new file mode 100644 index 00000000..b1f63939 --- /dev/null +++ b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql @@ -0,0 +1,52 @@ +-- 253_scrape_proxy_domclick_affinity_release.sql +-- Снять с узла резервацию provider_affinity='domclick' (#2800). +-- +-- WHY (замер, не рассуждение — живая проба 09.08.2026, тракт сайдкар+camoufox, +-- POST /fetch на robots.txt рабочего хоста каждой площадки): +-- +-- узел | affinity | avito | ekb.cian.ru | realty.yandex.ru | bff-search-web.domclick.ru +-- -----+----------+-------+--------------------+------------------+--------------------------- +-- 1 | domclick | 200 | 200 «Ошибка — Циан»| 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY +-- 9 | any | 200 | 200 | 200 | 200 +-- 10 | any | 200 | 200 | 200 | 200 +-- 11 | any | 200 | 200 | 200 | 200 +-- +-- Узел, закреплённый 173-й миграцией СПЕЦИАЛЬНО за Домкликом, до рабочего хоста +-- Домклика не доходит вообще (NS_ERROR_PROXY_BAD_GATEWAY на bff-search-web — +-- именно туда ходит боевой сбор, см. providers/domclick/serp.py::_BFF_BASE), при +-- этом Авито и Яндекс через него отвечают штатно. Резервация даёт ровно обратный +-- эффект задуманному: единственный источник, которому узел ГОДЕН НЕ БЫЛ, держал его +-- за собой, а два источника, которым он годен, его не видели — +-- acquire('avito'|'yandex') отбирает по provider_affinity IN (source,'any'), а +-- fallback этот узел не берёт (защита последнего узла выделенной affinity). +-- +-- 'any', а НЕ enabled=false: узел жив для двух площадок из четырёх, выключать его +-- целиком — терять четверть и без того дефицитного пула (#2638). +-- +-- WHAT: +-- provider_affinity='domclick' → 'any' для узлов, у которых affinity именно такая. +-- CHECK-констрейнт (173) не трогаем: значение 'domclick' остаётся допустимым, если +-- в пуле появится узел, который до Домклика реально доходит. +-- +-- ЧТО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ (граница честная): +-- Она НЕ чинит Домклик. acquire('domclick') и до неё видел все четыре узла +-- (affinity IN ('domclick','any')), т.е. шанс вытянуть узел 1 и потратить первый +-- бакет впустую был и остаётся 1/4 — закрывает это проба по паре «узел × источник» +-- (#2800 часть B), а не смена affinity. Здесь снимается только резервация. +-- +-- IDEMPOTENCY / SAFETY: +-- Один UPDATE в транзакции; повторный прогон не находит строк (no-op) — auto-apply +-- strict на деплое это требует. Блокирующего DDL нет (см. +-- scripts/check-migration-lock-timeout.py: правило про ALTER/DROP/CREATE INDEX), +-- UPDATE берёт row-lock на единичные строки. +-- +-- Dependencies: 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql + +BEGIN; + +UPDATE scrape_proxies +SET provider_affinity = 'any', + updated_at = now() +WHERE provider_affinity = 'domclick'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql new file mode 100644 index 00000000..61120223 --- /dev/null +++ b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql @@ -0,0 +1,155 @@ +-- 254_listings_backfill_avito_rating_glued_address.sql +-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814). +-- +-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же

+-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4 +-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но +-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет +-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при +-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от +-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный +-- путь, которым старые строки могут стать чистыми. +-- +-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным): +-- +-- класс адреса (source='avito', is_active) строк с координатами +-- ------------------------------------------ ------ -------------- +-- чистый 7892 6111 (77.4%) +-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%) +-- NULL 360 0 (0.0%) +-- +-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался +-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE. +-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет. +-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов +-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается). +-- +-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь. +-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile( +-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I) +-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t") +-- → `return cleaned or None`. +-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace = +-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов +-- в trim, NULLIF(...,'') = `or None`. +-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА. +-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост +-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми +-- (проверено в dry-run: 296 до = 296 после). +-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после +-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений +-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем. +-- +-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с +-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере: +-- docker exec tradein-scraper python /tmp/m2814-parity.py +-- → rows=1123 mismatches=0 +-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде. +-- +-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10): +-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296 +-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17 +-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б +-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103 +-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5 +-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60 +-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2 +-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2 +-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов +-- → жилой район Сортировочный, мкр-н Старая +-- Сортировка, Кунарская ул.,14к2 +-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов +-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18 +-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес +-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то +-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты: +-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.) +-- +-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится. +-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в +-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) — +-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк +-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного. +-- Откат (idempotent, безопасен к повторному запуску): +-- +-- UPDATE listings +-- SET address = raw_payload->>'address' +-- WHERE source = 'avito' +-- AND raw_payload->>'address' ~ '·\s*\d' +-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM +-- regexp_replace(raw_payload->>'address', +-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), ''); +-- +-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не +-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно +-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123. +-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный, +-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская +-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО: +-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам +-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует +-- в порчу. +-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные. +-- +-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по +-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ +-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит +-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не +-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не +-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен: +-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до +-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк. +-- +-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции +-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в +-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL +-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь +-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже +-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход +-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200, +-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC. +-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит +-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245 +-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт +-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53% +-- успеха на адрес, гадать точнее не буду. +-- +-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО: +-- * не трогает COALESCE в апсерте — поведение осознанное (#2777); +-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем +-- свипе (замерено: пустых строк '' среди них 0, все именно NULL); +-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть +-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city), +-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у +-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это +-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь. +-- +-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql +-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE +-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет. +-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает +-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815. +-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт +-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та +-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт +-- громко (ON_ERROR_STOP=on), чем встанет тихо. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +UPDATE listings +SET address = NULLIF( + trim(both E' ,.\n\t' FROM + regexp_replace( + address, + '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', + '', + 'i' + )), + ''), + geocode_tried_at = NULL +WHERE source = 'avito' + AND address ~ '·\s*\d'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql new file mode 100644 index 00000000..de19a1f4 --- /dev/null +++ b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql @@ -0,0 +1,77 @@ +-- 255_trade_in_estimates_revival_relaxations.sql +-- Номер сверен по `ls data/sql | sort` (max applied = 254) непосредственно +-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap +-- (108_*/084_* уже дублировались в прошлом). +-- +-- ── Incident 2026-08-10: «мёртвые» сохранённые оценки ─────────────────────── +-- Заказчик открыл сохранённую ссылку /trade-in/v2?id=... и увидел «НЕДОСТАТОЧНО +-- ДАННЫХ»: запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR +-- #2823/#2825) и лежит в БД с median_price=0/NULL, хотя тот же адрес и +-- параметры сейчас честно считаются (4 031 157 ₽ / 39 аналогов). 117 из 1071 +-- строк trade_in_estimates находятся в этом состоянии (29 за последние 30 +-- дней). GET /api/v1/trade-in/estimate/{id} (app/api/v1/trade_in.py:: +-- _try_revive_dead_estimate) теперь пересчитывает такую строку на месте и +-- пишет результат В ТУ ЖЕ строку (id/ссылка не меняются) — этому нужны две +-- новые колонки: +-- +-- revival_attempted_at — throttle: не пересчитывать чаще одного раза в N +-- минут (settings.trade_in_revival_throttle_minutes, default 10) на одну +-- строку. Заявка на пересчёт — атомарный conditional +-- `UPDATE ... WHERE revival_attempted_at IS NULL OR ... < NOW() - N min +-- RETURNING id` (тот же паттерн, что account_quota.increment, #747) — +-- защищает и от шторма повторных попыток на мёртвый адрес, и от гонки +-- двух параллельных GET (второй просто теряет заявку и отдаёт то, что +-- есть, без 500). +-- +-- ── relaxations / reliability (открытый хвост PR #2823, найден post-deploy +-- 2026-08-10 — см. fixes/Fix_Mera_Studio_Not_Estimated_Never_Block_Aug10) ── +-- Обе колонки УЖЕ вычисляются в estimator.estimate_quality() и уходят в POST- +-- ответ (AggregatedEstimate.relaxations/reliability), но раньше НЕ +-- персистились — на GET-rehydrate (открытие сохранённой ссылки) красный +-- баннер «точность снижена» пропадал, хотя цена по-прежнему построена на +-- расширенной/тонкой выборке. Теперь пишутся при каждом (re)compute (основной +-- INSERT в estimate_quality() + этот revival-путь) и читаются на GET. +-- +-- ── IDEMPOTENCY ─────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: все существующие +-- строки получают DEFAULT (reliability='ok', relaxations='[]', revival_ +-- attempted_at=NULL) — честно отражает то, что для них каскад послаблений +-- никогда не считался (записи ДО #oblast-F) и revival ещё не запускался. +-- +-- Dependencies: 001_trade_in_estimates.sql. Apply after: 254_*. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS relaxations jsonb NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS reliability text NOT NULL DEFAULT 'ok' + CHECK (reliability IN ('ok', 'low', 'very_low')); + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS revival_attempted_at timestamptz; + +COMMENT ON COLUMN trade_in_estimates.relaxations IS + 'RU-подписи применённых послаблений подбора (estimator.py #oblast-F cascade) ' + '— персистится, чтобы GET-rehydrate (?id=) мог восстановить дисклеймер ' + '«точность снижена». [] = базовой выборки хватило / запись создана до ' + '#oblast-F (без бэкфилла).'; + +COMMENT ON COLUMN trade_in_estimates.reliability IS + 'Надёжность итоговой выборки (ok|low|very_low), производная от n_analogs + ' + 'relaxations (estimator.py::estimate_quality) — персистится для GET-rehydrate ' + 'красного баннера. Default ok = запись создана до #oblast-F (без бэкфилла).'; + +COMMENT ON COLUMN trade_in_estimates.revival_attempted_at IS + 'Момент последней попытки пересчитать «мёртвую» (median_price<=0/NULL) ' + 'строку на GET /estimate/{id} (incident 2026-08-10, app/api/v1/trade_in.py::' + '_try_revive_dead_estimate). Throttle: не пересчитывать чаще одного раза в ' + 'settings.trade_in_revival_throttle_minutes на одну строку — атомарный ' + 'conditional UPDATE...RETURNING (см. модульный докстринг). NULL = либо ' + 'строка живая (median_price>0) и revival никогда не запускался, либо ' + 'запись создана до этой фичи.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql new file mode 100644 index 00000000..499f2f79 --- /dev/null +++ b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql @@ -0,0 +1,48 @@ +-- 256_trade_in_estimates_revival_completed_at.sql +-- Номер сверен по `ls data/sql | sort` (max applied = 255) непосредственно +-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap +-- (108_*/084_* уже дублировались в прошлом). +-- +-- ── fix/tradein-created (2026-08-11): created_at перезаписывался revival'ом ── +-- `_try_revive_dead_estimate` (app/api/v1/trade_in.py, migration 255) писал +-- пересчитанные поля в ИСХОДНУЮ строку trade_in_estimates, и вместе с ними — +-- `created_at`, скопированный из временной строки (estimate_quality() ставит +-- туда NOW() на момент пересчёта). Факт с прода: оценка +-- ff421062-cc38-4c4c-ad2e-0cfac52d14ff создана 2026-08-10 12:54:47, после +-- revival'а на GET её created_at стал 2026-08-11 04:30:03 — «дата обращения» +-- клиента (печатается в /history и в схеме AggregatedEstimate.created_at, +-- см. app/schemas/trade_in.py:317-318 «для метки «отчёт от DD.MM» в UI») +-- подменилась датой служебного пересчёта. Заодно ломался ORDER BY created_at +-- DESC в GET /history — оживлённая старая заявка выпрыгивала в начало списка. +-- +-- Фикс (app/api/v1/trade_in.py): created_at исключён из UPDATE SET revival'а, +-- исходная дата больше не трогается. Момент, когда revival РЕАЛЬНО пересчитал +-- строку (не просто "попытался" — revival_attempted_at из 255 ставится на +-- claim'е ДО вызова estimate_quality(), в том числе при throttle-проигрыше и +-- при неудачном пересчёте), нужен для аудита отдельно — новая колонка. +-- +-- ── IDEMPOTENCY ─────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: NULL = либо +-- строка живая и revival никогда успешно не пересчитывал, либо запись +-- создана до этой колонки. +-- +-- Dependencies: 255_trade_in_estimates_revival_relaxations.sql. Apply after: 255_*. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS revival_completed_at timestamptz; + +COMMENT ON COLUMN trade_in_estimates.revival_completed_at IS + 'Момент УСПЕШНОГО пересчёта «мёртвой» (median_price<=0/NULL) строки ' + 'revival''ом (app/api/v1/trade_in.py::_try_revive_dead_estimate) — ' + 'выставляется, когда пересчёт реально записал новые значения в строку. ' + 'Отличается от revival_attempted_at (255): тот ставится на claim''е ДО ' + 'вызова estimate_quality() и фиксирует ЛЮБУЮ попытку (включая throttled-' + 'проигрыш гонки и неудачный пересчёт), этот — только успех. created_at ' + 'строки при этом НЕ меняется (исходная дата обращения клиента, печатается ' + 'в /history) — см. fix/tradein-created 2026-08-11.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d8d5a0f..71642631 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -175,6 +175,7 @@ 168_fdw_osm_poi_ekb.sql 169_osm_poi_ekb_local.sql 170_scrape_schedules_seed_osm_poi_ekb_refresh.sql +171_scrape_schedules_seed_geoportal_coords_backfill.sql 172_trade_in_leads.sql 173_scrape_proxies_add_domclick_affinity.sql 174_domclick_session_cookies.sql @@ -190,17 +191,57 @@ 184_user_events.sql 185_account_quota_overrides.sql 186_tg_support.sql +187_web_support_chat.sql +188_tg_support_chat_id_scope.sql +189_account_estimate_usage_nonnegative.sql +190_sale_share_price_bucket_signature.sql +191_account_quota_unlimited_flag.sql +192_tradein_users_auth.sql +193_tradein_users_seed.sql +194_deal_city_price_bands_tiers.sql +195_scrape_schedules_seed_deal_city_price_bands_refresh.sql +196_listings_city.sql +197_backfill_listings_city_from_url.sql +198_scrape_proxy_rotations.sql +199_scrape_proxies_asocks_rotate_url.sql +200_region_code_foreign_cities.sql +201_purge_dead_mobileproxy_proxies.sql +202_listing_source_snapshot_budget_sec.sql +203_purge_geocode_cache_house_letter.sql +204_cian_oblast_sweeps_secondary.sql +205_sales_vs_listings_city_filter.sql +206_scrape_schedules_cut_wasteful_load.sql +207_backfill_yandex_cian_city_geo_cleanup.sql +208_reenable_domclick_detail_backfill.sql +209_scrape_proxies_disabled_reason.sql +210_scrape_proxy_source_bans.sql +211_sales_vs_listings_segment_guard.sql +212_sber_index_pull_weekly.sql +213_listings_snapshots_status_vocab.sql +214_drop_dead_run_metrics.sql +215_avito_full_load_window_matches_cadence.sql +216_dead_code_sweep.sql # -# 187_web_support_chat.sql / 188_tg_support_chat_id_scope.sql — НАМЕРЕННО НЕ -# добавлены (2026-07-27, devops-аудит). Прецедент из ЭТОГО же репо: -# commit 5eadae1e (fix(tradein/support): address deep-review ... L5) добавил -# и тут же убрал "187_web_support_chat.sql" из этого файла с формулировкой -# "keeping an unmerged migration name out of it preserves the option to -# rename before merge without tripping the "can't rename applied -# migrations" test". Обе миграции — часть веб-чата поддержки (#2532/#2533), -# который на момент этой правки ещё активно дорабатывается в параллельной -# сессии/окне (тот же фиче-набор, соседняя задача). Дописывать их сюда сейчас -# повторило бы именно ту ошибку, которую L5 исправил: заморозить имя файла -# ДО того как он гарантированно осел на проде в финальном виде. Когда фича -# стабилизируется и подтверждено, что 187/188 применены (_schema_migrations -# на проде) — дописать одной строкой в отдельном PR. +# 2026-08-06: список догнан до факта прода. Проверка перед правкой — +# _schema_migrations на tradein-postgres: 209 применённых имён, здесь было +# 178; расхождение — 31 имя, все в одну сторону (применено, но не заморожено). +# Обратного расхождения нет: ни одной строки, которой не было бы на проде. +# +# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат +# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на +# проде в финальном виде. Они в _schema_migrations — условие выполнено. +# +# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент +# правки они уже слиты в main и применены на проде (см. _schema_migrations), +# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш; +# self-maintenance-контракт (см. докстринг test_migrations_manifest.py) +# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для +# 222/225 по прецеденту 233_payments.sql. +222_db_audit_cleanup.sql +225_listing_source_snapshots_run_id_idx.sql +233_payments.sql +234_scrape_runs_ban_kind_unknown.sql +240_trade_in_estimates_retain_until.sql +250_drop_duplicate_expires_at_index.sql +251_listings_drop_ceiling_height.sql +254_listings_backfill_avito_rating_glued_address.sql diff --git a/tradein-mvp/backend/scripts/backfill_listing_sources.py b/tradein-mvp/backend/scripts/backfill_listing_sources.py index ad0b4283..e3e8c8ba 100644 --- a/tradein-mvp/backend/scripts/backfill_listing_sources.py +++ b/tradein-mvp/backend/scripts/backfill_listing_sources.py @@ -291,8 +291,8 @@ def _link_listing_to_house( lat=row.lat, lon=row.lon, year_built=row.year_built, + # Кадастр КВАРТИРЫ (row.cadastral_number) в ключ дома не идёт (#2674). building_cadastral_number=row.building_cadastral_number, - cadastral_number=row.cadastral_number, source_url=row.house_url or row.source_url, ) house_resolved = house_id is not None diff --git a/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py b/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py index ff0b65ce..08b6b70a 100644 --- a/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py +++ b/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py @@ -190,6 +190,10 @@ def run(jsonl_path: str, limit: int | None, dry_run: bool) -> dict[str, int]: db = SessionLocal() try: if lots: + # run_id нет и не будет (#2701): разовый ingest файла — не прогон скрапера, + # строки в scrape_runs под него не существует. Именно отсюда 129 908 снимков + # domklik без run_id (2.9% заполнения у источника) — исторические, не текущие: + # ежедневный run_domclick_city_sweep run_id передаёт. inserted, updated = save_listings( db, lots, matcher=RealMatcherAdapter(), region_code=DEFAULT_REGION_CODE ) diff --git a/tradein-mvp/backend/tests/conftest.py b/tradein-mvp/backend/tests/conftest.py index c6660a94..9651c161 100644 --- a/tradein-mvp/backend/tests/conftest.py +++ b/tradein-mvp/backend/tests/conftest.py @@ -1,13 +1,18 @@ """Repo-wide test config for tradein-mvp/backend. -Currently only registers custom pytest markers so they don't emit -PytestUnknownMarkWarning when used (`--strict-markers` is not enabled in -pyproject.toml, so an unregistered marker would only warn, not fail — this -just keeps output clean and documents intent in one place). +Регистрирует кастомные pytest-маркеры (иначе PytestUnknownMarkWarning: +`--strict-markers` в pyproject.toml не включён, так что незарегистрированный +маркер только предупреждал бы) и сторожит глобальное состояние, которое +переживает отдельный тест, — см. `_no_leaked_password_verify_slots`. """ from __future__ import annotations +import sys +from pathlib import Path + +import pytest + def pytest_configure(config) -> None: config.addinivalue_line( @@ -16,3 +21,107 @@ def pytest_configure(config) -> None: "Pango/cairo/GObject libs, self-skips where unavailable (see " "tests/test_pdf_real_render.py docstring for how to run it for real).", ) + + +@pytest.fixture(autouse=True) +def _no_leaked_password_verify_slots(): + """Тест не оставляет за собой занятых слотов проверки пароля (#2665, #2714). + + Счётчики в `app.core.password` — состояние ПРОЦЕССА, а `pytest-asyncio` даёт + каждому тесту свой событийный цикл. Слот освобождает колбэк, посланный в + цикл через `call_soon_threadsafe`; если цикл к тому моменту закрыт, + `_schedule_verify_slot_release` ловит RuntimeError и слот не возвращается + никогда. На проде цикл живёт столько же, сколько процесс, и ветка + недостижима — а в тестах она копится молча и роняет НЕ ТОТ тест, который + её устроил: при пуле в 1 поток пары утечек хватает, чтобы всё дальнейшее + получало 429 «на ровном месте». + + Поэтому проверка тут и общая: считаем слоты после каждого теста. + + `sys.modules.get`, а не import: тестам, которые password.py не трогают + (большинство), незачем тянуть `Settings()` с его требованием DATABASE_URL. + """ + yield + + password_mod = sys.modules.get("app.core.password") + if password_mod is None: + return + + inflight = password_mod._verify_inflight + by_key = dict(password_mod._verify_inflight_by_key) + # Сброс ДО assert: иначе одна утечка красит все последующие тесты и виновник + # теряется среди пострадавших. + password_mod._verify_inflight = 0 + password_mod._verify_inflight_by_key.clear() + + assert inflight == 0 and not by_key, ( + f"тест оставил {inflight} занятых слотов проверки пароля (по ключам: {by_key}) — " + "утечка слота при пуле в 1 поток это вечный 429 всем на входе" + ) + + +# ── Бюджет пропусков (#2745) ────────────────────────────────────────────────── +# +# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта: +# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и +# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI +# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть +# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в +# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал +# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно. +# +# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и +# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не +# ошибка; ошибка — пропуск без записи. +# +# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без +# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить. +# +# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона, +# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких +# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml). + +_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt" +_observed_skips: set[str] = set() + + +def _allowed_skips() -> set[str]: + if not _SKIP_ALLOWLIST_PATH.exists(): + return set() + lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines() + return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())} + + +def _record_skip(report) -> None: + if report.skipped: + # nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может + # отсутствовать, а не каждую её параметризацию. + _observed_skips.add(report.nodeid.split("[", 1)[0]) + + +def pytest_runtest_logreport(report) -> None: + _record_skip(report) + + +def pytest_collectreport(report) -> None: + # Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в + # logreport, и nodeid у него — путь файла. + _record_skip(report) + + +def pytest_sessionfinish(session, exitstatus) -> None: + unlisted = sorted(_observed_skips - _allowed_skips()) + if not unlisted: + return + print( + f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" + ) + for nodeid in unlisted: + print(f" - {nodeid}") + print( + "Почини тест либо внеси его в skip_allowlist.txt с причиной — " + "пропуск без записи неотличим от пройденной проверки." + ) + if exitstatus == 0: + session.exitstatus = 1 diff --git a/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html new file mode 100644 index 00000000..3fd69b35 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html @@ -0,0 +1 @@ +Ошибка - ЦианISIDPlhwqgqcowpM8uZOWWzawmWX3JbKNOWV5y8jbBk1QTMxXSbNTCYzv2eY56%2BzgvWctsm1nNNBInjiTIE4wTKyIXqNmtYlOzmhaTK60Tn8LgFw465ooSTZRlZbWNAVRqsSkSbwX3cLXr%2BXama%2BvH5PpteyqjVoMEnZZ9fy7U9Mq6lNhNqPwTg6YASdghUaijPGTLJGRj1xv2zyp3nGfDDlmBEufGFsQSmWpaquZAFvWbMSIVagrgmWV%2FpwZY%2B05W7zk%2B48X94xHfjeo42qibiytt3w1NTImtWX6RAUPZOEWRMJX%2FDAhDnp6PpcNPWJMlIzO54LIEgTLTbPMeT8Zcz8bbz8bbz8bbz8bcxqGIlysW6SCSEHnI3OmWtyI1qNpU%2BaVl4fL4yPXGUHMlzpFO8g7x3nL5nBgMCqbNNM489eVZ8CQV07DTtFVFNVZlZbprTPdHkXK8dpDGUHO2YoUriDzZ%2FPcifCVNlgCap0b6RzwJbvsziCR09wO2bk%2B2Fh0BtH1djPV2M9XYz1djPV2MtsmyQM5f7xfaMjZdAbCdhzBtLjB7V9hsvkNjwjKUT8C9Zw3rBwyJ6wsNWNoze1g2nkHtUNoereRt7INYvs9XYwnf%2FAM6pU5R2c0UDE8gAHwP%2FAIr%2FxAA4EQABAwEFBAgFAgcBAAAAAAABAAIRECExQVGRAxIgYQQiUHGBobHRMDKSwdJA8BMjM0JgguHx%2F9oACAEDAQk%2FAPgbQSMJtW01sTgRy7OMNF5z5BWoJxgYK%2FEdmXus1oN5y2e6cx%2F2aXO7MuF6vCvx76f22I%2FK5vjJ7MxCwVrcimhs440F8Hs0dceYqLMEI7OHeEOqELB2g2Ozf%2FV8hN2QlGQUBC3UIPDu7qIgRhwRu4LdjQ13Ykwt0jzrgmhrcAb%2FABujuW7GivoC52QtPeYuHNbuyH1fiulR3NP5KH8gI%2B5Tf4buZ9wKxHNbqvqBGMrdKEOx4jACsYLh9zQ9QrIff4vOuayqOoNZ9qZ0vRlxvKEqE2EIcLjzXzM%2BDnw3L%2BmDfmeAzFnxedc1lQwSChAGFM6P3T3LbDQo2Zoq0H1WC%2BY%2FBz4f9jlyq0gH4%2FOuayV6Mk%2FuPBCwIRLQVnQwBem7ozdf4DEeK6S7ws9Cukv%2Bo%2B66Q8xhvO96Z1dDsDetsNFtm6LbN0Tt5wvMRTPgM7S7uqIi6ULCjIFLyhB4mzPC2RbwDqAWnxQIGHMZoWBXDzRuaAhNqvRkNN2EhCSiGhbb96LbQB97BhmihZQyBI0KEpiYmIQUEIqf5jssOavNBZhKuoIeL7L62u9RwCQaGokGhoJFLWnDgzKNGx3Zmhmgkug2YQZBPIkRQ2UAA5c6PKedU86oyeC%2BLFa5xv8Aag6oVgHBdiOeYpejaQOHaBhzIn1BXSR9I%2FFdJH0j8V0mQCLA0W%2BQptA05kT6grpE8g0ew4L65mtxV62IIzk%2ByY1nMkzpulHecbzn7dws4OXHlUWLrNOcSPRCLOESCpJm0EjyuQ63C2R3hM9EzzCZ5hM8wmeiEFBYpkjvCZ5j3QgCo3gbcL1s9SPdC1XH9yjvDIkSPRMKYVbOATZaeYTIH21oJB7ls%2FT3Qg1E6LZye8e6EH9C4hPOpTzqU86lPOpTzqUZKyo4jxTifHgcQnnWpTynnWjiE4mjiE86ngMJ51P6hknkmGf8M%2F%2FZ" alt="logo">

Обнаружен подозрительный трафик

Просмотрите возможные причины ошибки по ссылке ниже. Если ошибка повторяется, напишите в службу поддержки или на почту support@cian.ru.

Укажите в письме эти данные:

  • IP-адрес: 46.8.110.92
  • ID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366
  • Код страницы: cian_waf_block
" +) + + +@pytest.mark.asyncio +async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None: + """Блок → `report_ban`, пока lease ещё жив. + + Без этого пул продолжает выдавать сожжённый узел, и следующий дом идёт с того же + IP — ровно то, что происходило 200 попыток подряд. + """ + fetcher = _spy_fetcher(waf_html) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"): + result = await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + assert result is None + fetcher.report_ban.assert_called_once() + assert "cian_waf_block" in fetcher.report_ban.call_args.args[0] + # Бан репортится ДО выхода из контекста — иначе lease уже отпущен и вызов no-op. + assert fetcher.__aexit__.await_count == 1 + assert "cian_waf_block" in "\n".join(r.getMessage() for r in caplog.records) + + +@pytest.mark.asyncio +async def test_parsed_page_never_bans_the_node_even_with_captcha_word(monkeypatch) -> None: + """Разобранная страница НЕ банит узел, даже если на ней написано «captcha». + + Живая регрессия 2026-08-09: первая версия этой правки звала `_blocked_by` на КАЖДОМ + ответе, и за первые 17 секунд прогона забанила два ЗДОРОВЫХ узла (9 и 10) на + страницах, которые успешно разобрались — обычная карточка ЖК грузит скрипт + SmartCaptcha, подстрока там есть всегда. Признак «есть подпись защиты» осмыслен + только вместе с «состояния нет»: список маркеров создавался объяснять УЖЕ + случившийся отказ разбора, а не служить самостоятельным детектором. + """ + fetcher = _spy_fetcher(_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + # Предпосылка теста: страница действительно и разбирается, и содержит слово-ловушку. + assert result is not None + assert result.cian_internal_house_id == 108855 + assert "captcha" in _HEALTHY_CARD_WITH_CAPTCHA_SCRIPT + fetcher.report_ban.assert_not_called() + + +@pytest.mark.asyncio +async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) -> None: + """Провод целиком: provider из задачи → build_browser_fetcher → пул. + + Красный на старом коде дважды: аргумента `proxy_provider` там не было, и фабрика + для этого провайдера не вызывалась вовсе (BrowserFetcher конструировался вручную, + без пула). + """ + factory = MagicMock(return_value=_spy_fetcher(waf_html)) + monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_browser_fetcher", factory) + provider = object() + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000") + + await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", config=config, proxy_provider=provider + ) + + factory.assert_called_once_with(config, "cian", proxy_provider=provider) + + +# ── новый фронт Циана: тот же ключ под другим именем MFE ────────────────────── + +# Форма живой страницы zhk-kosmos-ekb-i.cian.ru (2026-08-09): состояние лежит под +# 'newbuilding-card-desktop-fichering-frontend', СТАРОГО имени на странице нет вовсе. +_FICHERING_CARD = ( + "" +) + + +@pytest.mark.asyncio +async def test_state_is_found_under_the_renamed_fichering_mfe(monkeypatch) -> None: + """ЖК на новом фронте Циана разбирается — вместе с графиком цен. + + Именно из-за этого имени `houses_price_dynamics` стояла с 2026-07-26: страница + приходила целиком, состояние в ней было, но под другим MFE — и отказ выглядел как + «разметка изменилась», хотя форма состояния та же. + """ + fetcher = _spy_fetcher(_FICHERING_CARD) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-kosmos-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + assert result is not None + assert result.cian_internal_house_id == 3235089 + assert result.name == "Космос" + # График — тот самый, ради которого задача и существует. + assert [p["month_date"] for p in result.realty_valuation_chart] == [ + "2026-06-01", + "2026-07-01", + ] + assert [p["price_per_sqm"] for p in result.realty_valuation_chart] == [4100000.0, 4250000.0] + fetcher.report_ban.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py new file mode 100644 index 00000000..fea61b82 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -0,0 +1,200 @@ +"""#2767 — сделать причину отказа newbuilding_enrich видимой. + +Восемь суток подряд прогон делал 25 попыток, обогащал ноль записей и закрывался +статусом 'done'. Различить «страница пришла целиком, изменилась разметка» и +«пришла защитная заглушка» было нельзя: в логе не было ни размера ответа, ни +признаков защиты, а статус утверждал успех. + +Две проверки, обе падают на коде до правки: + 1. строка отказа разбора несёт размер полученной страницы и найденные маркеры; + 2. прогон с ненулевыми попытками и нулём обогащений финализируется через общий + runs.mark_backfill_finished (#2695), а не через mark_done. + +Ни сети, ни БД: BrowserFetcher подменён спаем, финализаторы — заглушками. +""" + +from __future__ import annotations + +import logging +import os +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +# DATABASE_URL required by config before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint stub — not installed in CI without GTK. +sys.modules.setdefault("weasyprint", MagicMock()) + +import pytest # noqa: E402 +from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 + _describe_parse_miss, + fetch_newbuilding, +) + +from app.tasks import newbuilding_enrich_backfill as task_mod # noqa: E402 +from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402 + NewbuildingEnrichBackfillResult, + run_newbuilding_enrich, +) + +# --------------------------------------------------------------------------- +# 1. Размер страницы + маркеры защиты в строке отказа разбора. +# --------------------------------------------------------------------------- + + +def test_describe_parse_miss_reports_size_and_no_markers() -> None: + """Целая страница без маркеров: размер есть, маркеров нет → «изменилась разметка».""" + html = "" + "x" * 1_170_000 + "" + described = _describe_parse_miss(html) + assert f"html_len={len(html)}" in described + assert "antibot_markers=none" in described + + +def test_describe_parse_miss_names_found_markers() -> None: + """Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного. + + Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть + на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после + того, как забанила здоровые узлы (#2767). + """ + described = _describe_parse_miss("
Вы не робот?
") + assert "вы не робот" in described + assert "html_len=" in described + + +def _spy_browser_fetcher(html: str) -> MagicMock: + """Подмена фабрики фетчера: async-CM, чей .fetch() отдаёт заданный html.""" + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=html) + fetcher.__aenter__ = AsyncMock(return_value=fetcher) + fetcher.__aexit__ = AsyncMock(return_value=None) + return MagicMock(return_value=fetcher) + + +@pytest.mark.asyncio +async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None: + """Отказ разбора обязан писать РАЗМЕР полученной страницы. + + Размер один гипотезы уже НЕ разделяет (страница блока Циана весит 374 КБ — см. + test_2767_cian_waf_block.py), но остаётся обязательным фактом в строке отказа. + """ + shell = "Доступ ограничен" # короткая заглушка + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + _spy_browser_fetcher(shell), + ) + + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"): + result = await fetch_newbuilding( + "https://zhk-tihiy-centr-ekb-i.cian.ru", + config=SimpleNamespace( + browser_http_endpoint="http://tradein-browser:3000", + use_proxy_pool_browser=False, + ), + ) + + assert result is None + message = "\n".join(r.getMessage() for r in caplog.records) + assert "initialState extraction failed" in message + assert f"html_len={len(shell)}" in message + assert "доступ ограничен" in message + + +# --------------------------------------------------------------------------- +# 2. Честный статус: ноль обогащений при ненулевых попытках ≠ 'done'. +# --------------------------------------------------------------------------- + + +def _stub_finalisers(monkeypatch: pytest.MonkeyPatch) -> dict: + """Заглушить lifecycle-функции; вернуть журнал вызовов финализаторов.""" + calls: dict = {} + monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_done", + lambda _db, run_id, counters: calls.setdefault("mark_done", dict(counters)), + ) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda _db, run_id, err, counters: calls.setdefault("mark_failed", err), + ) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_backfill_finished", + lambda _db, run_id, counters, **kw: calls.setdefault( + "finished", {"counters": dict(counters), **kw} + ), + ) + return calls + + +def _stub_backfill( + monkeypatch: pytest.MonkeyPatch, result: NewbuildingEnrichBackfillResult +) -> None: + async def _fake(_db, **_kwargs): + return result + + monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake) + + +@pytest.mark.asyncio +async def test_zero_enrichment_run_goes_through_shared_finaliser(monkeypatch) -> None: + """Прод-картина: 25 попыток, 0 успехов, 25 отказов fetch. Финал — общий + mark_backfill_finished (который такой прогон помечает 'failed'), НЕ mark_done.""" + _stub_backfill( + monkeypatch, + NewbuildingEnrichBackfillResult(processed=25, succeeded=0, failed_fetch=25), + ) + calls = _stub_finalisers(monkeypatch) + + await run_newbuilding_enrich(object(), run_id=42, params={}) # type: ignore[arg-type] + + assert "mark_done" not in calls # ← падает на старом коде + finished = calls["finished"] + assert finished["source"] == "newbuilding_enrich" + assert finished["counters"]["attempted"] == 25 + assert finished["counters"]["enriched"] == 0 + assert finished["counters"]["failed"] == 25 + # Диагноза «виновата площадка» здесь нет: блоки не считаются вовсе (#2764). + assert finished["counters"].get("blocked") is None + assert "fetch" in finished["fail_hint"] + + +@pytest.mark.asyncio +async def test_all_skipped_run_reports_zero_attempts(monkeypatch) -> None: + """Все выбранные дома уже обогащены → attempted=0: честная пустота, а не отказ + (общий финализатор такой прогон закрывает 'done').""" + _stub_backfill( + monkeypatch, + NewbuildingEnrichBackfillResult(processed=25, skipped_already_enriched=25), + ) + calls = _stub_finalisers(monkeypatch) + + await run_newbuilding_enrich(object(), run_id=43, params={}) # type: ignore[arg-type] + + assert calls["finished"]["counters"]["attempted"] == 0 + assert calls["finished"]["fail_hint"] is None + + +@pytest.mark.asyncio +async def test_partial_success_keeps_rich_counters(monkeypatch) -> None: + """Ключи финализатора ДОБАВЛЯЮТСЯ к своим счётчикам задачи, а не заменяют их.""" + _stub_backfill( + monkeypatch, + NewbuildingEnrichBackfillResult( + processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7 + ), + ) + calls = _stub_finalisers(monkeypatch) + + await run_newbuilding_enrich(object(), run_id=44, params={}) # type: ignore[arg-type] + + counters = calls["finished"]["counters"] + assert counters["attempted"] == 10 + assert counters["enriched"] == 3 + assert counters["failed"] == 7 + assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте + assert counters["succeeded"] == 3 diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py new file mode 100644 index 00000000..37fc10fe --- /dev/null +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -0,0 +1,456 @@ +"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ. + +Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь — +верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный +ответ означал «узел годен для Авито», а читался как «узел годен вообще». + +Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация): + + узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru + -----+----------+-------+---------------------+--------------+--------------------------- + 1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY + 10 | any | 200 | 200 | 200 | 200 + +Что сторожится (каждый тест ниже падает на коде до фикса): + + 1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для + Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')` + выдавал его как ни в чём не бывало. + 2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен + (`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной). + 3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех. + 4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и + забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что + «дешёвая ipify стирает браузерный вердикт» в #2723). + 5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой + вердикт #2723 сохранён). + 6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс). + 7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +import scraper_kit.browser_fetcher as bf + +from app.services import proxy_pool +from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release +from tests.services.test_proxy_pool import FakeSession, _proxy + +# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным. +_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = { + ("http://u:p@h1:8080", "domclick"): ( + False, + "proxy", + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + ), +} + + +def _patch_probes( + monkeypatch: pytest.MonkeyPatch, + matrix: dict[tuple[str, str], tuple[bool, str | None, str]], + calls: list[tuple[str, str]] | None = None, + *, + default: tuple[bool, str | None, str] = (True, None, "html_len=16477"), +) -> None: + """ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник).""" + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _fake_browser( + endpoint: str, proxy_url: str, **kw: Any + ) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + if calls is not None: + calls.append((proxy_url, source)) + return matrix.get((proxy_url, source), default) + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) + + +# ── 1. главное: вердикт разведён по источникам ─────────────────────────────── + + +async def test_node_dead_for_domclick_is_not_issued_to_domclick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём. + + Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при + `browser_fail_streak=0` и свежей пробе. + """ + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + # Домклику достаётся только исправный узел… + first = acquire(db, "domclick") # type: ignore[arg-type] + assert first is not None and first.id == 2 + + # …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить + # заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой + # заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0. + assert acquire(db, "domclick") is None, ( # type: ignore[arg-type] + "Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY" + ) + + # Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла. + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None and lease.id == 1 + + ban = db._ban(1, "domclick") + assert ban is not None + assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба" + assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None + assert counters["pair_banned"] == 1 + + +async def test_one_dead_platform_does_not_stain_the_node_globally( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает.""" + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + row = db._by_id(1) + assert row["browser_unfit_since"] is None + assert row["browser_fail_streak"] == 0 + assert row["enabled"] is True and row["consecutive_fails"] == 0 + + +async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел).""" + dead_everywhere = { + ("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable") + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, dead_everywhere) + db = FakeSession([_proxy(1), _proxy(2)]) + + for _ in range(BROWSER_UNFIT_THRESHOLD): + db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._by_id(1)["browser_unfit_since"] is not None + assert db._by_id(2)["browser_unfit_since"] is None + + +# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ──────────────────────────────── + + +@pytest.mark.parametrize( + ("source", "must_contain"), + [ + ("avito", "www.avito.ru"), + ("cian", "ekb.cian.ru"), + ("yandex", "realty.yandex.ru"), + # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, + # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. + ("domclick", "bff-search-web.domclick.ru"), + ], +) +async def test_probe_asks_the_working_host_of_each_source( + monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str +) -> None: + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = '{"html": "
User-agent: *
"}' + + @staticmethod + def json() -> dict[str, str]: + return {"html": "
User-agent: *
"} + + 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 + return _Resp() + + 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=source + ) + + assert ok is True + assert must_contain in seen["payload"]["url"] + assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет + # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии. + assert seen["payload"]["source"] == "generic" + + +async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы.""" + stub = "Ошибка - Циан…" + + class _Resp: + status_code = 200 + text = "{}" + + @staticmethod + def json() -> dict[str, str]: + return {"html": stub} + + 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: + return _Resp() + + 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="cian" + ) + + assert ok is False + # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. + assert fail_kind == "page" + assert "not robots.txt" in detail + + +async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя.""" + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "cian") is not None + assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём + + +# ── 4. проба снимает только свою строку ────────────────────────────────────── + + +async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором. + + robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить + дорогой вердикт значило бы повторить #2723 на паре. + """ + later = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": later, + "ban_count": 1, + "reason": "banned:avito", # распознан боевым сбором (капча/QRATOR) + }, + { + "proxy_id": 1, + "source": "cian", + "banned_until": later, + "ban_count": 1, + "reason": "probe:browser", # прошлый вердикт самой пробы + }, + ], + ) + _patch_probes(monkeypatch, {}) # все площадки отвечают + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + avito = db._ban(1, "avito") + assert avito is not None, "чужой бан проба снимать не имеет права" + assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать" + assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять" + assert counters["pair_cleared"] == 1 + + +# ── 4b. …и не присваивает чужую (дефект #2803, реализовался на проде) ───────── + + +async def test_probe_does_not_steal_a_live_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Упавшая проба НЕ переписывает активный бан, поставленный боевым сбором. + + Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21` после + упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. Фильтр «снимаю только + своё» при этом цел, но защищать перестаёт: присвоенная строка уже «своя», и + следующая успешная проба сняла бы ею бан, который площадка поставила по-настоящему. + Плюс сама метка перестаёт быть свидетельством («нас отбили» неотличимо от «мы не + смогли», #2764), а ban_count складывает события разного рода в одну эскалацию — + отдых пары вырос с 6 ч до 12 ч. + """ + until = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": until, + "ban_count": 1, + "reason": "banned:cian", # боевой сбор: Циан отдал заглушку + } + ], + ) + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + ban = db._ban(1, "cian") + assert ban is not None + assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора" + assert ban["ban_count"] == 1, "два события разного рода посчитаны одной эскалацией" + assert ban["banned_until"] == until, "чужой срок проба не пересчитывает (может и укоротить)" + assert counters["pair_banned"] == 0, "счётчик не должен объявлять баном то, чего не записал" + + +async def test_live_ban_takes_over_the_probe_row(monkeypatch: pytest.MonkeyPatch) -> None: + """Зеркало намеренно НЕ симметрично: боевой сбор строку пробы перехватывает. + + Его вердикт сильнее — площадка отбила нас именно сейчас, — пара остаётся забаненной, + а метка становится точнее. Если запретить и ему, строка останется за пробой, и её же + зелёный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально. + """ + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": datetime.now(UTC) + timedelta(hours=6), + "ban_count": 1, + "reason": "probe:browser", + } + ], + ) + + proxy_pool.mark_banned(db, 1, source="cian") # type: ignore[arg-type] + + assert db._ban(1, "cian")["reason"] == "banned:cian" + + # …и с этой минуты зелёная проба его не снимет — ради чего перехват и нужен. + _patch_probes(monkeypatch, {}) + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert db._ban(1, "cian") is not None + + +# ── 5-6. чужие отказы ──────────────────────────────────────────────────────── + + +async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + """Лежащий сайдкар не должен забанить разом все пары (#2686-класс).""" + down = { + (f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError") + for pid in (1, 2) + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, down) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db.bans == [] + assert db._by_id(1)["browser_unfit_since"] is None + + +# ── 7. цена такта ──────────────────────────────────────────────────────────── + + +async def test_probe_cost_is_nodes_times_servable_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Крест ограничен источниками, которым узел вообще может достаться. + + Диагностика не должна превращаться в нагрузку: узел с выделенной affinity + спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника + = 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на + площадку в сутки против ~1000 боевых /fetch. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, {}, calls) + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [ + "avito", + "cian", + "domclick", + "yandex", + ] + assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"] + assert len(calls) == 5 + + +async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None: + """Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки. + + Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из + теста выше не меняется, пока всё зелено. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, _LIVE_MATRIX, calls) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert calls.count(("http://u:p@h1:8080", "domclick")) == 2 + assert calls.count(("http://u:p@h1:8080", "avito")) == 1 + + +async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """Один провал, второй заход зелёный → бан пары не пишется.""" + attempts: dict[str, int] = {} + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + key = f"{proxy_url}|{source}" + attempts[key] = attempts.get(key, 0) + 1 + if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1: + return False, "proxy", "503 browser unavailable" + return True, None, "html_len=150" + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db._ban(1, "domclick") is None + lease = acquire(db, "domclick") # type: ignore[arg-type] + assert lease is not None + release(db, lease.id) # type: ignore[arg-type] diff --git a/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py new file mode 100644 index 00000000..69b93713 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py @@ -0,0 +1,193 @@ +"""#2807: счётчик мерил прирост таблицы, а читался как «сколько записали». + +`newbuilding_enrich_backfill` считал свою работу разницей `COUNT(*)` до и после +сохранения. Вставка в houses_price_dynamics идёт `ON CONFLICT … DO UPDATE`, поэтому +обновление существующей точки давало ноль. Прод 10.08: прогон 3578 отчитался +`price_dynamics_rows: 0`, обновив за своё окно **64 строки по 10 домам** — те самые, +что вставил прогон 3563 накануне (у него в тех же counters стояло 64). Ноль читался +как «динамика цен снова не пишется». + +Соседние счётчики врали в том же месте по своим причинам: `reliability_rows` обнулял +`_dedup_reliability`, схлопывающий строку сразу после вставки, а `review_rows` +игнорировал число, которое `_save_cian_reviews` УЖЕ возвращал, в пользу разницы COUNT'ов. + +Фальсификация (см. прогон в PR): на коде до правки `test_second_pass_reports_updates` +даёт `price_dynamics_rows == 0` при 64 переписанных точках — ровно прод-симптом. + +Отдельно проверяется, что правка НЕ ослабила сторожа нулевого результата: он смотрит на +`attempted`/`enriched`/`gone`/`blocked` (#2695), а не на счётчики записи, и прогон, +который ничего не обогатил, обязан остаться 'failed' при любых числах в `*_written`. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +sys.modules.setdefault("weasyprint", MagicMock()) + +from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 + NewbuildingSaveCounts, + save_newbuilding_enrichment, +) + +from app.services import scrape_runs as runs_mod # noqa: E402 +from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402 + NewbuildingEnrichBackfillResult, +) + +# Прод-масштаб прогона 3578: 10 домов × 64/10 точек. Держим ровно 64, чтобы число в +# тесте совпадало с числом в задаче. +PROD_POINTS = 64 + + +class _UpsertDB: + """Сессия, у которой houses_price_dynamics уже населена (второй проход). + + `RETURNING (xmax = 0)` возвращает False на конфликте — это и есть «обновили». + """ + + def __init__(self, *, already_present: bool) -> None: + self.already_present = already_present + self.price_writes = 0 + self.reliability_writes = 0 + self.committed = False + + def execute(self, statement, params=None): + sql = str(statement) + res = MagicMock() + if "INSERT INTO houses_price_dynamics" in sql: + self.price_writes += 1 + assert "RETURNING (xmax = 0)" in sql, "писатель обязан различать вставку и update" + res.fetchone.return_value = (not self.already_present,) + return res + if "INSERT INTO house_reliability_checks" in sql: + self.reliability_writes += 1 + res.fetchone.return_value = None + return res + + def commit(self) -> None: + self.committed = True + + +def _enrichment(points: int): + from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment + + return NewbuildingEnrichment( + cian_internal_house_id=1, + cian_zhk_url="https://zhk-x.cian.ru/", + name="ЖК Тест", + realty_valuation_chart=[ + { + "month_date": f"2026-{(i % 12) + 1:02d}-01", + "room_count": "all", + "prices_type": "price", + "period": "halfYear", + "price_per_sqm": 150000.0 + i, + } + for i in range(points) + ], + reliability_checks=[{"check_name": "Надёжный застройщик", "check_status": "reliable"}], + reviews=[], + ) + + +# ── 1. Писатель различает вставку и обновление ─────────────────────────────── + + +def test_first_pass_reports_inserts() -> None: + db = _UpsertDB(already_present=False) + counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS)) + assert counts.price_inserted == PROD_POINTS + assert counts.price_updated == 0 + assert counts.reliability_inserted == 1 + assert db.price_writes == PROD_POINTS + + +def test_second_pass_reports_updates() -> None: + """Прод-симптом: те же 64 точки, ничего нового — но записаны все 64. + + До правки этот прогон отчитывался нулём по всем трём счётчикам. + """ + db = _UpsertDB(already_present=True) + counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS)) + assert counts.price_inserted == 0 + assert counts.price_updated == PROD_POINTS + assert counts.price_written == PROD_POINTS + assert db.price_writes == PROD_POINTS + + +def test_nothing_to_write_stays_zero() -> None: + """Встречная проверка: пустой график — ноль и во «вставлено», и в «обновлено».""" + db = _UpsertDB(already_present=True) + counts = save_newbuilding_enrichment(db, 42, _enrichment(0)) + assert (counts.price_inserted, counts.price_updated, counts.price_written) == (0, 0, 0) + + +def test_points_without_price_are_not_counted_as_written() -> None: + """Точка без price_per_sqm пропускается писателем — и не попадает в счёт.""" + enrichment = _enrichment(2) + enrichment.realty_valuation_chart[0]["price_per_sqm"] = None + db = _UpsertDB(already_present=False) + counts = save_newbuilding_enrichment(db, 42, enrichment) + assert counts.price_written == 1 + assert db.price_writes == 1 + + +# ── 2. Сторож нулевого результата не ослаблен ──────────────────────────────── + + +def _finalize(counters: dict[str, int]) -> str: + """Прогнать counters через боевой финализатор и вернуть выбранный статус.""" + chosen: dict[str, str] = {} + with ( + patch.object(runs_mod, "mark_done", lambda *a, **k: chosen.setdefault("s", "done")), + patch.object(runs_mod, "mark_failed", lambda *a, **k: chosen.setdefault("s", "failed")), + patch.object(runs_mod, "mark_banned", lambda *a, **k: chosen.setdefault("s", "banned")), + ): + runs_mod.mark_backfill_finished(MagicMock(), 1, counters, source="newbuilding_enrich") + return chosen["s"] + + +def test_watchdog_still_fails_a_run_that_enriched_nothing() -> None: + """Прогон без обогащений остаётся 'failed', сколько бы записей ни насчитали. + + Числа записи в решение сторожа не входят вовсе — он судит по attempted/enriched. + Если бы входили, честный счётчик «обновлено» превратил бы холостой прогон в успех. + """ + result = NewbuildingEnrichBackfillResult( + processed=25, + succeeded=0, + failed_fetch=25, + price_dynamics_updated=PROD_POINTS, # «что-то писали» — но никого не обогатили + ) + assert _finalize(result.to_backfill_counters()) == "failed" + + +def test_watchdog_verdict_ignores_the_new_keys() -> None: + """Явно: добавление/убирание новых ключей не двигает вердикт ни в одну сторону.""" + base = {"attempted": 25, "enriched": 3, "failed": 22} + assert _finalize(dict(base)) == "done" + assert _finalize({**base, "price_dynamics_inserted": 0, "price_dynamics_updated": 0}) == "done" + zero = {"attempted": 25, "enriched": 0, "failed": 25} + assert _finalize(dict(zero)) == "failed" + assert _finalize({**zero, "price_dynamics_updated": 999}) == "failed" + + +def test_counters_carry_both_numbers_into_the_run() -> None: + """В scrape_runs.counters уезжают ОБА числа — ноль одного больше не читается как ноль.""" + counters = NewbuildingEnrichBackfillResult( + processed=25, succeeded=25, price_dynamics_updated=PROD_POINTS + ).to_backfill_counters() + assert counters["price_dynamics_inserted"] == 0 + assert counters["price_dynamics_updated"] == PROD_POINTS + # Старые имена не должны остаться: у них в истории прогонов другой смысл. + assert "price_dynamics_rows" not in counters + assert "reliability_rows" not in counters + assert "review_rows" not in counters + + +def test_save_counts_written_is_the_sum() -> None: + assert NewbuildingSaveCounts(price_inserted=3, price_updated=4).price_written == 7 diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py new file mode 100644 index 00000000..a6fb3820 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py @@ -0,0 +1,340 @@ +"""#2830: хвосты инцидента 2026-08-10 — пути, которые ещё ходили мимо пула прокси. + +Корень (issue #2830, разбор владельца): `SCRAPER_PROXY_URL` — один статичный узел, +общий для всех источников и НЕ знающий про `scrape_proxy_source_bans`. Три места +продолжали брать egress именно оттуда: + + 1. `cian_price_history` (ручка POST /admin/scrape/cian-price-history); + 2. `_provider_proxy_url` / GET /admin/scraper/health — ops-панель показывала статичный + узел, пока трафик уже выбирался пулом (#2825/#2831); + 3. `resolve_cian_zhk_url_via_search` — вторая нога обогащения ЖК: #2767 перевёл на пул + только `fetch_newbuilding`, резолв ЖК-url остался на `config.cian_proxy_url`. + +Тесты проверяют ПОВЕДЕНИЕ пула (какой узел взят и какой вердикт вернулся), а не наличие +kwarg'а в вызове. Красные на старом коде: + + * (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не + сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»: + `USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`, + где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`). + * (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел + параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан» + выражения не имел вообще. + * (3) `resolve_cian_zhk_url_via_search` на 403 возвращал None: узел получал + `mark_health(ok=True)` и оставался в выдаче Циану (механика #2700/#2821). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.cian_exceptions import CianBlockedError +from scraper_kit.contracts import ProxyLease +from scraper_kit.providers.cian import detail as cian_detail +from scraper_kit.providers.cian import newbuilding as cian_newbuilding +from scraper_kit.proxy_errors import NoProxyAvailableError + +# Узел 9 (asocks-mobile-1) — тот, что 2026-08-10 отдавал Циану 200, пока статичный +# SCRAPER_PROXY_URL смотрел на забаненный узел 1 (asocks-residential-1). +_LEASE = ProxyLease(id=9, url="http://user:pass@pool-node-9:10313", kind="http", rotate_url=None) + + +class _SpyProvider: + """ProxyProvider-заглушка: запоминает вердикты, которые путь вернул пулу.""" + + def __init__(self, lease: ProxyLease | None = _LEASE) -> None: + self._lease = lease + self.acquire_calls: list[str] = [] + self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] + self.release_calls: list[int] = [] + + def acquire(self, provider: str) -> ProxyLease | None: + self.acquire_calls.append(provider) + return self._lease + + def release(self, lease: ProxyLease) -> None: + self.release_calls.append(lease.id) + + def mark_health( + self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None + ) -> None: + self.mark_health_calls.append((lease.id, ok)) + + def mark_banned(self, lease: ProxyLease, *, source: str) -> None: + self.mark_banned_calls.append((lease.id, source)) + + +class _SpyPool: + """Заглушка МОДУЛЯ `app.services.proxy_pool` — подставляется ПОД `RealProxyProvider`. + + Так тест краснеет на поведении (пул не получил вердикт), а не на отсутствии нового + имени в модуле: старый код проходит ровно тот же путь, просто ничего пулу не говорит. + """ + + def __init__(self, lease: Any = _LEASE) -> None: + self._lease = lease + self.acquire_calls: list[str] = [] + self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] + self.release_calls: list[int] = [] + + def acquire(self, db: Any, provider: str, **kw: Any) -> Any: + self.acquire_calls.append(provider) + return self._lease + + def release(self, db: Any, lease_id: int) -> None: + self.release_calls.append(lease_id) + + def mark_health(self, db: Any, lease_id: int, ok: bool, **kw: Any) -> None: + self.mark_health_calls.append((lease_id, ok)) + + def mark_banned(self, db: Any, lease_id: int, *, source: str) -> None: + self.mark_banned_calls.append((lease_id, source)) + + +def _session_returning(status_code: int, text: str = "") -> MagicMock: + session = MagicMock() + session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text)) + session.close = AsyncMock() + return session + + +# ── 1. cian_price_history: узел из пула + вердикт обратно ──────────────────── + + +def _price_history_db(n_listings: int) -> MagicMock: + """Фейковая сессия: SELECT листингов, дальше COUNT'ы (до fetch дело не дойдёт).""" + rows = [ + {"id": 100 + i, "source_url": f"https://ekb.cian.ru/sale/flat/{100 + i}/"} + for i in range(n_listings) + ] + listings_result = MagicMock() + listings_result.mappings.return_value.all.return_value = rows + db = MagicMock() + db.execute.return_value = listings_result + return db + + +async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any: + from app.services import scraper_adapters + from app.services.cian_price_history import backfill_cian_price_history + + with ( + patch.object( + cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code) + ), + patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0), + patch.object(scraper_adapters, "_proxy_pool", pool), + patch.object(scraper_adapters, "_SessionLocal", MagicMock()), + ): + return await backfill_cian_price_history(_price_history_db(n), batch_size=n) + + +@pytest.mark.asyncio +async def test_price_history_takes_pool_node_despite_flag_off() -> None: + """Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend). + + Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного + pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал. + """ + from app.core.config import settings + + assert ( + settings.use_proxy_pool_curl is False + ), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL" + pool = _SpyPool() + await _run_price_history(pool, status_code=200) + assert pool.acquire_calls == ["cian"] + assert pool.release_calls == [9] # lease не течёт + + +@pytest.mark.asyncio +async def test_price_history_403_bans_the_node_for_cian() -> None: + """403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = [].""" + pool = _SpyPool() + result = await _run_price_history(pool, status_code=403) + assert pool.mark_banned_calls == [(9, "cian")] + assert pool.mark_health_calls == [(9, False)] + assert result.errors == 1 # прогон честен: отказ посчитан + + +@pytest.mark.asyncio +async def test_price_history_404_does_not_ban_the_node() -> None: + """Снятое объявление — не бан площадки: чужой вердикт узлу не присваиваем (#2805).""" + pool = _SpyPool() + await _run_price_history(pool, status_code=404) + assert pool.mark_banned_calls == [] + assert pool.mark_health_calls == [(9, True)] + + +@pytest.mark.asyncio +async def test_price_history_aborts_batch_when_pool_empty() -> None: + """Пул пуст в проде → отказ ДО HTTP и разрыв батча, а не 3 попытки подряд.""" + from app.core.config import settings + + pool = _SpyPool(lease=None) + # Прод-признак читает `curl_proxy_url` через config.environment (#2616 шаг 1): + # только в проде пустой пул = отказ, в dev это легитимный env/direct-путь. + with patch.object(settings, "environment", "production"): + result = await _run_price_history(pool, status_code=200, n=3) + assert pool.acquire_calls == ["cian"], "после отказа пула батч продолжаться не должен" + assert result.errors == 1 + assert result.checked == 3 # выбрали 3, но оборвались на первом + + +def test_no_proxy_available_is_not_a_platform_ban() -> None: + """Наш отказ инфраструктуры не должен читаться как бан площадки (#2616).""" + from scraper_kit.proxy_errors import ProxyBanError + + assert not issubclass(NoProxyAvailableError, ProxyBanError) + + +# ── 2. /admin/scraper/health: показывает узел, который реально получит трафик ── + + +def _health_db(*, candidate: dict[str, Any] | None, diag: dict[str, int]) -> MagicMock: + """Сессия для `proxy_egress`: SELECT кандидата, при пустом — SELECT-разбивка причин.""" + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + row = diag if "count(*)" in str(stmt).lower() else candidate + res = MagicMock() + res.mappings.return_value.fetchone.return_value = row + return res + + db = MagicMock() + db.execute.side_effect = _execute + return db + + +def _get_health(db: MagicMock) -> dict[str, Any]: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + def _fake_db() -> Any: + yield db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + app.dependency_overrides[get_db] = _fake_db + + async def _no_browser() -> Any: + return admin_module.BrowserHealth(reachable=False, browsers={}) + + async def _no_ip(proxy_url: str | None) -> str | None: + return None + + with ( + patch.object(admin_module, "_probe_browser_health", _no_browser), + patch.object(admin_module, "_probe_current_ip", _no_ip), + # Статичный env-узел ОТЛИЧАЕТСЯ от пулового — иначе тест не различит источники. + patch.object( + admin_module.settings.__class__, + "scraper_proxy_url", + property(lambda _self: "http://u:p@static-env-node:10423"), + ), + ): + r = TestClient(app).get("/api/v1/admin/scraper/health") + assert r.status_code == 200 + return {p["source"]: p for p in r.json()["providers"]} + + +def test_health_shows_pool_node_not_static_env() -> None: + """Панель показывает узел, который получит трафик, а не SCRAPER_PROXY_URL. + + Красный на старом коде поведенчески: возвращался `static-env-node` — ровно то + расхождение «панель показывает один узел, трафик идёт через другой», из-за + которого инцидент 2026-08-10 месяц оставался невидимым. + """ + db = _health_db( + candidate={"id": 9, "url": _LEASE.url, "label": "asocks-mobile-1"}, + diag={"pool_total": 4, "banned_for_source": 0, "unhealthy_or_disabled": 0}, + ) + by_source = _get_health(db) + assert {p["proxy_host"] for p in by_source.values()} == {"pool-node-9"} + assert by_source["cian"]["proxy_port"] == 10313 + + +def test_health_shows_nothing_when_pool_exhausted_for_source() -> None: + """Все узлы забанены для источника → «—», а не статичный env-узел. + + Зелёная строка на месте отказа хуже пустой: она читается как «прокси есть». + """ + db = _health_db( + candidate=None, + diag={"pool_total": 4, "banned_for_source": 4, "unhealthy_or_disabled": 0}, + ) + by_source = _get_health(db) + assert by_source["cian"]["proxy_host"] is None + assert by_source["cian"]["proxy_port"] is None + + +# ── 3. resolve ЖК-url: вторая нога обогащения (#2767 чинил только первую) ───── + + +@dataclass +class _FakeConfig: + use_proxy_pool_curl: bool = True + cian_proxy_url: str | None = "http://static-env-node:10423" + environment: str = "production" + + +async def _resolve(status_code: int, spy: _SpyProvider, html: str = "") -> Any: + with patch.object( + cian_newbuilding, + "build_curl_cffi_session", + return_value=_session_returning(status_code, html), + ): + return await cian_newbuilding.resolve_cian_zhk_url_via_search( + 48853, config=_FakeConfig(), proxy_provider=spy + ) + + +@pytest.mark.asyncio +async def test_zhk_resolve_uses_pool_node() -> None: + """Резолв ЖК-url берёт узел из пула, а не статичный cian_proxy_url.""" + spy = _SpyProvider() + html = '

ЖК

' + assert await _resolve(200, spy, html) == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" + assert spy.acquire_calls == ["cian"] + assert spy.mark_health_calls == [(9, True)] + assert spy.release_calls == [9] + + +@pytest.mark.asyncio +async def test_zhk_resolve_403_reaches_the_pool() -> None: + """Красный на старом коде: 403 гасился в `return None`, узел оставался в выдаче.""" + spy = _SpyProvider() + with pytest.raises(CianBlockedError): + await _resolve(403, spy) + assert spy.mark_banned_calls == [(9, "cian")] + assert spy.mark_health_calls == [(9, False)] + assert spy.release_calls == [9] + + +@pytest.mark.asyncio +async def test_zhk_resolve_404_is_a_soft_miss_not_a_ban() -> None: + """404 cat.php — не бан: здоровый узел за отсутствующий ЖК не наказываем.""" + spy = _SpyProvider() + assert await _resolve(404, spy) is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] + + +@pytest.mark.asyncio +async def test_zhk_resolve_empty_serp_is_a_soft_miss_not_a_ban() -> None: + """200 без слага (дрейф вёрстки / пустая выдача) — тоже не повод банить узел.""" + spy = _SpyProvider() + assert await _resolve(200, spy, "ничего") is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] diff --git a/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py new file mode 100644 index 00000000..884a7c8a --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py @@ -0,0 +1,290 @@ +"""Offline tests для Cian cookie-session admin-эндпоинтов (#инцидент 2026-08-10). + +cian_session_svc.verify_session() возвращает 5 разных исходов (state dict / None / +VERIFY_BAN_SENTINEL / VERIFY_SOURCE_UNAVAILABLE_SENTINEL / VERIFY_MARKUP_CHANGED_SENTINEL) +и раньше admin.py проверял только `if state is None`, из-за чего реальный бан по IP +(403) и недоступность источника (5xx) выглядели как "куки протухли" — человек в +момент инцидента перезаливал заведомо валидные куки вместо починки egress/прокси. + +Покрытие 3 эндпоинтов (db/verify_session/BrowserFetcher мокаются, NO live network/DB), +зеркалит паттерн test_domclick_admin_apis.py (dependency_overrides[get_db] + TestClient): + - POST /api/v1/admin/scrape/cian/upload-cookies + - POST /api/v1/admin/scrape/cian/auto-login + - GET /api/v1/admin/scrape/cian/test-auth +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.services.cian_session import ( + VERIFY_BAN_SENTINEL, + VERIFY_MARKUP_CHANGED_SENTINEL, + VERIFY_SOURCE_UNAVAILABLE_SENTINEL, +) + + +@pytest.fixture +def client() -> TestClient: + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + + def fake_db(): + yield MagicMock() + + app.dependency_overrides[get_db] = fake_db + return TestClient(app) + + +_UPLOAD_URL = "/api/v1/admin/scrape/cian/upload-cookies" +_AUTOLOGIN_URL = "/api/v1/admin/scrape/cian/auto-login" +_TEST_AUTH_URL = "/api/v1/admin/scrape/cian/test-auth" + +_RAW_COOKIES = {"DMIR_AUTH": "abc", "_CIAN_GK": "def"} +_AUTHENTICATED_STATE = {"user": {"isAuthenticated": True, "userId": 102963817}} +_AUTHENTICATED_STATE_NO_USERID = {"user": {"isAuthenticated": True}} + + +# ── POST /scrape/cian/upload-cookies — пять исходов verify_session ──────────── + + +def test_upload_cookies_ban_returns_503(client: TestClient) -> None: + """403 TLS/bot ban → 503, текст говорит чинить прокси, НЕ перезаливать куки.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 503 + detail = resp.json()["detail"] + assert "заблокировал" in detail + assert "прокси" in detail or "egress" in detail + assert "бесполезно" in detail + # Текст обязан называть ЖИВУЮ переменную: инцидент 2026-08-10 — правка мёртвой + # CIAN_PROXY_URL не давала эффекта, реальный egress задаётся SCRAPER_PROXY_URL. + assert "SCRAPER_PROXY_URL" in detail + + +def test_upload_cookies_source_unavailable_returns_503(client: TestClient) -> None: + """5xx/сеть → 503, текст говорит повторить позже, куки не трогать.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 503 + detail = resp.json()["detail"] + assert "недоступен" in detail + assert "позже" in detail + + +def test_upload_cookies_markup_changed_returns_500(client: TestClient) -> None: + """200 но auth-state не распарсился → 500, текст указывает на фикс парсера.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 500 + detail = resp.json()["detail"] + assert "вёрстк" in detail + assert "перезалив" in detail + + +def test_upload_cookies_expired_returns_401(client: TestClient) -> None: + """None → 401, текст просит перезалить куки (единственный случай re-upload).""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=None), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 401 + assert "перезалейте" in resp.json()["detail"] + + +def test_upload_cookies_success_missing_userid_returns_400(client: TestClient) -> None: + """Успешный state, но без userId → 400 с уточнением что куки тут ни при чём.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE_NO_USERID), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 400 + detail = resp.json()["detail"] + assert "userId" in detail + assert "куки тут ни при" in detail + + +def test_upload_cookies_success_saves_and_returns_200(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save, + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["userId"] == 102963817 + mock_save.assert_called_once() + _, kwargs = mock_save.call_args + assert kwargs["account_user_id"] == 102963817 + + +# ── POST /scrape/cian/auto-login — тот же маппинг после успешного browser-login ── + + +def _mock_browser_fetcher(raw_cookies: dict[str, str]) -> MagicMock: + fetcher = AsyncMock() + fetcher.__aenter__ = AsyncMock(return_value=fetcher) + fetcher.__aexit__ = AsyncMock(return_value=None) + fetcher.login = AsyncMock(return_value=raw_cookies) + return fetcher + + +def test_auto_login_ban_returns_503(client: TestClient) -> None: + """Browser login прошёл, но verify_session ловит 403 ban → 503, не "куки протухли".""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"), + patch("app.api.v1.admin.settings.cian_login_password", "secret"), + patch( + "app.api.v1.admin.BrowserFetcher", + return_value=_mock_browser_fetcher(_RAW_COOKIES), + ), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.post(_AUTOLOGIN_URL) + assert resp.status_code == 503 + assert "заблокировал" in resp.json()["detail"] + + +def test_auto_login_success_saves_and_returns_200(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"), + patch("app.api.v1.admin.settings.cian_login_password", "secret"), + patch( + "app.api.v1.admin.BrowserFetcher", + return_value=_mock_browser_fetcher(_RAW_COOKIES), + ), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save, + ): + resp = client.post(_AUTOLOGIN_URL) + assert resp.status_code == 200 + assert resp.json()["userId"] == 102963817 + mock_save.assert_called_once() + + +# ── GET /scrape/cian/test-auth — reason различает все пять исходов ──────────── + + +def test_test_auth_ban_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + body = resp.json() + assert body["authenticated"] is False + assert body["reason"] == "banned_403" + + +def test_test_auth_source_unavailable_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "source_unavailable" + + +def test_test_auth_markup_changed_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "markup_changed" + + +def test_test_auth_expired_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=None), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "session_expired_or_invalid" + + +def test_test_auth_success(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + body = resp.json() + assert body["authenticated"] is True + assert body["userId"] == 102963817 + assert body["reason"] is None diff --git a/tradein-mvp/backend/tests/test_admin_stale_counters.py b/tradein-mvp/backend/tests/test_admin_stale_counters.py new file mode 100644 index 00000000..4fd4cbf1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_stale_counters.py @@ -0,0 +1,205 @@ +"""#2660: админ-счётчики честно разделяют «активно» и «из них не виделись N дней». + +Почему это баг, а не косметика: `is_active` снимается только деактиватором протухших, +а он покрывает не все источники — прод (2026-08-05) показывал cian 18 530 «активных» +при 12 683 не виденных 14+ дней и 10 212 не виденных 30+. Именно поэтому #2574 месяц +читалась как «всё собирается». Счётчик не прячем (иначе потеряем видимость проблемы) — +отдаём вторым числом рядом. + +Покрыто (db мокается, NO live network/DB): + - GET /api/v1/admin/scraper/data-quality — stale_count per source + stale_days; + - GET /api/v1/trade-in/cache-stats — listings_active_stale + listings_stale_days; + - порог свежести = LISTINGS_FRESH_DAYS эстиматора (не второе определение); + - оба запроса считают протухшее по last_seen_at И только среди is_active. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +class _FakeResult: + def __init__( + self, + *, + rows: list[dict] | None = None, + one: dict | None = None, + scalar_value: Any = None, + ) -> None: + self._rows = rows or [] + self._one = one + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + def mappings(self) -> Any: + outer = self + + class _Mappings: + def all(self) -> list[dict]: + return outer._rows + + def one(self) -> dict: + assert outer._one is not None + return outer._one + + def fetchone(self) -> dict | None: + return outer._one + + return _Mappings() + + +class _FakeDB: + """Session stand-in: execute() отдаёт очередь заготовленных результатов.""" + + def __init__(self, results: list[_FakeResult]) -> None: + self._results = list(results) + self.executed: list[tuple[Any, Any]] = [] + + def execute(self, clause: Any, params: Any = None) -> _FakeResult: + self.executed.append((clause, params)) + return self._results.pop(0) + + +# Одна строка coverage-выборки: source + счётчики + fill-агрегаты по всем полям. +def _dq_row(source: str, active: int, stale: int) -> dict[str, Any]: + from app.api.v1.admin import _DQ_LISTING_FIELDS + + row: dict[str, Any] = {"source": source, "active_count": active, "stale_count": stale} + for name, _expr in _DQ_LISTING_FIELDS: + row[f"f_{name}"] = active # 100% fill — не предмет этого теста + return row + + +_HOUSES_ROW = {"total": 10, "validated_cnt": 5, "rating_cnt": 5, "house_type_cnt": 5} + + +def _make_client(db: _FakeDB, router_module: str, prefix: str) -> TestClient: + import importlib + + from app.core.db import get_db + + module = importlib.import_module(router_module) + app = FastAPI() + app.include_router(module.router, prefix=prefix) + app.dependency_overrides[get_db] = lambda: db + return TestClient(app) + + +# ── GET /scraper/data-quality ──────────────────────────────────────────────── + + +@pytest.fixture +def dq_db() -> _FakeDB: + return _FakeDB( + [ + _FakeResult( + rows=[ + # Прод-пропорции 2026-08-05: cian почти весь протухший, avito живой. + _dq_row("cian", 18_530, 12_683), + _dq_row("avito", 4_975, 0), + ] + ), + _FakeResult(one=_HOUSES_ROW), + _FakeResult(scalar_value=0), + ] + ) + + +def test_data_quality_reports_stale_next_to_active(dq_db: _FakeDB) -> None: + """«Активно» остаётся как было, рядом появляется «из них не виделись N дней».""" + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + r = client.get("/api/v1/admin/scraper/data-quality") + assert r.status_code == 200 + body = r.json() + by_source = {s["source"]: s for s in body["sources"]} + assert by_source["cian"]["active_count"] == 18_530 + assert by_source["cian"]["stale_count"] == 12_683 + # Живой источник — протухших нет, счётчик не «шумит» на здоровых данных. + assert by_source["avito"]["active_count"] == 4_975 + assert by_source["avito"]["stale_count"] == 0 + + +def test_data_quality_exposes_the_threshold_used(dq_db: _FakeDB) -> None: + """Порог отдаётся в ответе — UI подписывает число, а не хардкодит своё N.""" + from app.services.estimator import LISTINGS_FRESH_DAYS + + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + body = client.get("/api/v1/admin/scraper/data-quality").json() + assert body["stale_days"] == LISTINGS_FRESH_DAYS + + +def test_data_quality_counts_stale_by_last_seen_within_active_only(dq_db: _FakeDB) -> None: + """Протухшее считается по last_seen_at (буквально «не виделись») и ТОЛЬКО среди + активных — иначе счётчик смешает деактивированные строки и перестанет быть + сопоставимым с active_count.""" + from app.services.estimator import LISTINGS_FRESH_DAYS + + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + client.get("/api/v1/admin/scraper/data-quality") + clause, params = dq_db.executed[0] + sql = str(clause) + assert "COUNT(*) FILTER (" in sql + assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql + assert "WHERE is_active = true" in sql + assert params == {"fresh_days": LISTINGS_FRESH_DAYS} + + +# ── GET /trade-in/cache-stats ──────────────────────────────────────────────── + + +def _cache_stats_row() -> dict[str, Any]: + return { + "geocode_cache": 1, + "geocode_cache_fresh": 1, + "listings_active": 37_900, + "listings_active_stale": 20_935, + "listings_last_scraped": None, + "deals": 0, + "cad_buildings": 0, + "house_metadata": 0, + "estimates_total": 0, + "avg_median_price": None, + "repeat_address_pct": None, + } + + +def test_cache_stats_splits_active_and_stale() -> None: + from app.services.estimator import LISTINGS_FRESH_DAYS + + db = _FakeDB([_FakeResult(one=_cache_stats_row())]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + body = client.get("/api/v1/trade-in/cache-stats").json() + assert body["listings_active"] == 37_900 + assert body["listings_active_stale"] == 20_935 + assert body["listings_stale_days"] == LISTINGS_FRESH_DAYS + + +def test_cache_stats_counts_stale_by_last_seen_within_active_only() -> None: + from app.services.estimator import LISTINGS_FRESH_DAYS + + db = _FakeDB([_FakeResult(one=_cache_stats_row())]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + client.get("/api/v1/trade-in/cache-stats") + clause, params = db.executed[0] + sql = str(clause) + assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql + assert "listings_active_stale" in sql + assert params == {"fresh_days": LISTINGS_FRESH_DAYS} + + +def test_cache_stats_empty_db_returns_empty_dict_not_a_fake_threshold() -> None: + """Нет строки — отдаём {}, а не {listings_stale_days: N} с пустыми счётчиками + (иначе UI отрисует подпись к несуществующему числу).""" + db = _FakeDB([_FakeResult(one=None)]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + assert client.get("/api/v1/trade-in/cache-stats").json() == {} diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py new file mode 100644 index 00000000..ae084d93 --- /dev/null +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -0,0 +1,379 @@ +"""Сигналы о сбоях действительно становятся событиями GlitchTip (#2674). + +Почему обычной проверки уровня записи мало. В контейнере скрапера GlitchTip поднят +как `LoggingIntegration(level=INFO, event_level=ERROR)` (app/scheduler_main.py) — +значит WARNING остаётся строкой в docker-логе (которая теряется на редеплое) и +событием НЕ становится. Прод-цена этого: монитор устаревания СберИндекса отработал +24 раза, 9 из них со staleness-вердиктом — событий ноль; куки Домклика протухли +2026-08-03 — событий ноль. + +Поэтому здесь тесты проверяют ФАКТ СОБЫТИЯ, а не levelno: `glitchtip_events()` +поднимает настоящий sentry-клиент с той же интеграцией и тем же event_level, что в +проде, но с транспортом-списком. Если правку откатить (ERROR → WARNING), список +останется пустым и тест покраснеет. + +Оговорка, которую тесты проверить не могут: у GlitchTip-проекта сейчас нет ни правил, +ни получателей (#2673) — события будут видны в интерфейсе, но никому не отправятся. + +Без сети, без БД. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, date, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import domclick_session as domclick_session_svc +from app.services import rosreestr_poll, sber_index +from app.tasks import deals_freshness_monitor as deals_mon +from app.tasks import domclick_detail_backfill as dc_backfill +from app.tasks import sber_freshness_monitor as sber_mon + +# ── харнесс: настоящий клиент GlitchTip с прод-настройками, транспорт — список ── + + +@contextmanager +def glitchtip_events() -> Iterator[list[dict[str, Any]]]: + """Собрать события так, как их увидел бы GlitchTip из контейнера скрапера. + + Интеграция и event_level — копия app/scheduler_main.py. Клиент ставится только + на время блока (isolation_scope), глобальное состояние не трогаем. + """ + events: list[dict[str, Any]] = [] + + def _collect(event: dict[str, Any], _hint: dict[str, Any]) -> None: + """before_send: событие уже собрано — записываем и НЕ отправляем (None).""" + events.append(event) + return None + + client = sentry_sdk.Client( + dsn="https://public@localhost/1", + before_send=_collect, + default_integrations=False, + integrations=[LoggingIntegration(level=logging.INFO, event_level=logging.ERROR)], + ) + with sentry_sdk.isolation_scope() as scope: + scope.set_client(client) + try: + yield events + finally: + # Иначе на каждый тест остаётся фоновый поток транспорта. + client.close() + + +def event_texts(events: list[dict[str, Any]]) -> list[str]: + """Тексты событий — и логовых (logentry), и явных capture_message (message).""" + texts: list[str] = [] + for event in events: + entry = event.get("logentry") + if isinstance(entry, dict): + texts.append(str(entry.get("formatted") or entry.get("message") or "")) + elif isinstance(event.get("message"), str): + texts.append(event["message"]) + return texts + + +def test_harness_itself_drops_warnings() -> None: + """Мета-проверка харнесса: WARNING не событие, ERROR — событие. + + Без этого зелёные тесты ниже ничего не доказывали бы (пустой список мог бы быть + следствием сломанного харнесса, а не сломанного алерта). + """ + log = logging.getLogger("test_alerts_become_events.meta") + with glitchtip_events() as events: + log.warning("тихо") + log.error("громко") + assert event_texts(events) == ["громко"] + + +# ── 1. СберИндекс: монитор устаревания ──────────────────────────────────────── + + +class _FakeMonitorDB: + """Session-мок мониторов свежести: один SELECT max(...).""" + + def __init__(self, latest: date | None) -> None: + self._latest = latest + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + result = MagicMock() + result.first.return_value = MagicMock(latest=self._latest) + return result + + def rollback(self) -> None: + pass + + +def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None: + monkeypatch.setattr(module.runs_mod, "update_heartbeat", lambda *a, **k: None) + monkeypatch.setattr(module.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None) + + +def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие.""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(date(2026, 6, 1)) + with glitchtip_events() as events: + out = sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=1, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert out["alert"] == 1 + assert any( + "sber freshness" in t for t in event_texts(events) + ), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает" + + +def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Свежие данные — ни одного события (иначе алерт-усталость).""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(date(2026, 6, 1)) + 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), + ) + assert out["alert"] == 0 + assert event_texts(events) == [] + + +def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Бенчмарк пуст — монитор не может работать вовсе; mark_failed виден только стрику.""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(None) + with glitchtip_events() as events: + sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=3, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert any("sber_price_index пуст" in t for t in event_texts(events)) + + +def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Тот же класс у соседнего монитора сделок — найдено «шире» по #2674.""" + _patch_runs(monkeypatch, deals_mon) + db = _FakeMonitorDB(None) + with glitchtip_events() as events: + deals_mon.check_deals_freshness( + db, # type: ignore[arg-type] + run_id=4, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert any("deals пуста" in t for t in event_texts(events)) + + +# ── 2. СберИндекс: 404 датасета (почему бенчмарк перестаёт обновляться) ──────── + + +async def test_sber_index_404_becomes_event() -> None: + """404 = переименованный slug: сбой ПЕРМАНЕНТНЫЙ, а был тише соседних 5xx-веток.""" + request = httpx.Request("GET", "https://sberindex.ru/api/sowa") + + def _raise_404(*args: Any, **kwargs: Any) -> Any: + raise httpx.HTTPStatusError( + "404", request=request, response=httpx.Response(404, request=request) + ) + + with ( + patch.object(sber_index, "fetch_sber_index", _raise_404), + glitchtip_events() as events, + ): + counters = await sber_index.pull_sber_indices( + MagicMock(), + cities={"66": "Свердловская область"}, + dashboards=[sber_index.SBER_DASHBOARDS[0]], + ) + + assert counters["errors"] == 1 + assert any("404 for dashboard" in t for t in event_texts(events)) + + +# ── 3. Куки Домклика: по факту и ЗАРАНЕЕ (образец — #2658 для Циана) ────────── + + +class _FakeDomclickDB: + """Session-мок: строка кук для session_expires_at + пустой снапшот листингов.""" + + def __init__(self, expires_at: datetime | None) -> None: + self._expires_at = expires_at + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + result = MagicMock() + if "domclick_session_cookies" in str(stmt): + result.first.return_value = None if self._expires_at is None else (self._expires_at,) + else: + # snapshot листингов — пусто, прогон завершится до BrowserFetcher + result.mappings.return_value.all.return_value = [] + return result + + +async def _run_domclick( + monkeypatch: pytest.MonkeyPatch, + *, + cookies: dict[str, str] | None, + expires_at: datetime | None, +) -> list[str]: + _patch_runs(monkeypatch, dc_backfill) + monkeypatch.setattr(domclick_session_svc, "load_session", lambda _db: cookies) + db = _FakeDomclickDB(expires_at) + with glitchtip_events() as events: + await dc_backfill.run_domclick_detail_backfill( + db, # type: ignore[arg-type] + run_id=10, + params={}, + ) + return event_texts(events) + + +async def test_domclick_expired_cookies_become_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод 2026-08-03: протухли, единственным следом был WARNING в docker-логе.""" + expired = datetime.now(tz=UTC) - timedelta(days=3) + texts = await _run_domclick(monkeypatch, cookies=None, expires_at=expired) + assert any("протухли" in t for t in texts), "протухшие куки не стали событием" + # Дата в тексте — чтобы оператор сразу видел, чинить сейчас или это давняя дыра. + assert any(expired.strftime("%Y-%m-%d") in t for t in texts) + + +async def test_domclick_missing_cookies_reason_differs(monkeypatch: pytest.MonkeyPatch) -> None: + """«Кук нет вовсе» и «протухли» лечатся одинаково, но диагностируются по-разному.""" + texts = await _run_domclick(monkeypatch, cookies=None, expires_at=None) + assert any("кук DomClick нет в БД" in t for t in texts) + + +async def test_domclick_warns_before_expiry(monkeypatch: pytest.MonkeyPatch) -> None: + """Предупреждение ЗАРАНЕЕ: куки ещё рабочие, но жить им меньше порога. + + Сигнал по факту протухания приходит, когда обогащение уже встало, а обновление + кук — ручная операция. По образцу #2658 (Циан). + """ + soon = datetime.now(tz=UTC) + timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS - 1) + texts = await _run_domclick(monkeypatch, cookies={"CAS_ID": "x"}, expires_at=soon) + assert any("протухнут" in t for t in texts), "не предупредили заранее" + + +async def test_domclick_fresh_cookies_are_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Свежие куки — ни одного события.""" + far = datetime.now(tz=UTC) + timedelta(days=25) + texts = await _run_domclick(monkeypatch, cookies={"CAS_ID": "x"}, expires_at=far) + assert texts == [] + + +def test_domclick_expiry_query_asks_for_the_used_row() -> None: + """valid_only=True — срок ИМЕННО той записи, которую взял бы load_session. + + При нескольких аккаунтах свежайшая-любая может быть чужой протухшей строкой. + """ + db = MagicMock() + db.execute.return_value.first.return_value = None + domclick_session_svc.session_expires_at(db, valid_only=True) + assert db.execute.call_args.args[1] == {"valid_only": True} + + +# ── 4. Поллер Росреестра: сбой каталога vs выход квартала ───────────────────── + + +def _client_returning(status_code: int) -> MagicMock: + client = MagicMock() + client.get = AsyncMock(return_value=httpx.Response(status_code, text="")) + return client + + +async def test_rosreestr_broken_index_becomes_event() -> None: + """Каталог не отдаёт 200 — поллер слеп. Портал уже один раз переезжал.""" + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available( + _client_returning(503), 2026, 3 + ) + assert available is False + assert any("unexpected HTTP 503" in t for t in event_texts(events)) + + +async def test_rosreestr_stub_instead_of_zip_becomes_event() -> None: + """Файл есть в листинге, но HEAD отдал заглушку — тот сбой, из-за которого уже врали. + + Ровно поведение старой Bitrix-заглушки: HTTP 200 + text/html вместо архива. + """ + index_html = 'q' + folder_html = 'f' + client = MagicMock() + client.get = AsyncMock( + side_effect=[ + httpx.Response(200, text=index_html), + httpx.Response(200, text=folder_html), + ] + ) + client.head = AsyncMock( + return_value=httpx.Response( + 200, text="stub", headers={"content-type": "text/html", "content-length": "512"} + ) + ) + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available(client, 2026, 3) + + assert available is False + assert any("soft-404 guard" in t for t in event_texts(events)) + + +async def test_rosreestr_quarter_not_published_is_silent() -> None: + """Каталог жив, папки квартала ещё нет — самый частый прогон, событий быть не должно.""" + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available( + _client_returning(200), 2026, 3 + ) + assert available is False + assert event_texts(events) == [] + + +async def test_rosreestr_timeout_stays_out_of_events() -> None: + """Осознанно НЕ событие: транспортный блип раз в 28 дней сам пройдёт. + + «Квартал так и не приехал» ловит deals_freshness_monitor по max(deal_date). + """ + client = MagicMock() + client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + with glitchtip_events() as events: + await rosreestr_poll.check_new_quarter_available(client, 2026, 3) + assert event_texts(events) == [] + + +async def test_rosreestr_new_quarter_becomes_event() -> None: + """Хорошая новость — тоже событие: она требует ручного импорта оператором. + + Уровень info, а не error: событие в GlitchTip есть, а error-rate и стрик-алерты + не начинают врать про «сбой». INFO-строка в логе живёт до ближайшего редеплоя. + """ + with ( + patch.object(rosreestr_poll, "latest_loaded_quarter", MagicMock(return_value=(2026, 2))), + patch.object(rosreestr_poll, "check_new_quarter_available", AsyncMock(return_value=True)), + glitchtip_events() as events, + ): + out = await rosreestr_poll.poll_rosreestr_new_quarter(MagicMock()) + + assert out == { + "available": True, + "year": 2026, + "quarter": 3, + "latest_loaded_year": 2026, + "latest_loaded_quarter": 2, + } + assert any("доступен новый квартал Q3 2026" in t for t in event_texts(events)) diff --git a/tradein-mvp/backend/tests/test_audit_api.py b/tradein-mvp/backend/tests/test_audit_api.py index 78ec6684..a3226678 100644 --- a/tradein-mvp/backend/tests/test_audit_api.py +++ b/tradein-mvp/backend/tests/test_audit_api.py @@ -53,6 +53,39 @@ def test_days_param_uses_cast_as_int() -> None: assert "CAST(:days AS int)" in _AUDIT_SRC +def test_every_group_by_username_filters_out_the_nameless() -> None: + """Каждая выборка «по аккаунтам» отбрасывает строки с пустым именем (#2715). + + Пустое имя пишет `login_verify_saturated`: отказ по насыщению случается ДО + того, как мы посмотрели на присланное имя, и записать его нельзя — иначе + атакующий набивал бы аудит строками с любым именем на выбор. Но аккаунтом + такая строка от этого не становится: без фильтра она встаёт ПЕРВОЙ в списке + (её `last_seen_at` — момент атаки), даёт фантома в `count(DISTINCT + username)`, а раскрытие уходит в `/audit/accounts/{username}` с + `min_length=1` — то есть в ошибку. + + То же и со счётчиками уникальных: `count(DISTINCT username)` считал бы + безымянного за человека, и первая же атака НАВСЕГДА добавила бы +1 к числу + пользователей (строка остаётся в таблице). `NULLIF(username, '')` роняет её + в NULL, который `count(DISTINCT)` не считает. Сами события при этом из + `total_events` не исчезают — они события, просто не люди. + + Сравнение ЧИСЛОМ, а не поиском подстроки: так сторож ловит и НОВУЮ выборку, + добавленную без фильтра, а не только сегодняшние. На проде пустых имён + сейчас 0 из 3365 строк — то есть это ново. + """ + grouped = _AUDIT_SRC.count("GROUP BY username") + filtered = _AUDIT_SRC.count("WHERE username <> ''") + assert grouped == filtered, ( + f"{grouped} выборок GROUP BY username, из них с фильтром {filtered} — " + "безымянная строка попадёт в список аккаунтов" + ) + assert "count(DISTINCT username)" not in _AUDIT_SRC, ( + "count(DISTINCT username) считает безымянные события за людей — " + "нужен count(DISTINCT NULLIF(username, ''))" + ) + + # --------------------------------------------------------------------------- # Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc. # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 6f3d3da1..9cd7b5cd 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -31,14 +31,18 @@ in-memory fake DB standing in for the identity registry: from __future__ import annotations +import asyncio +import logging import os import re +import time from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Annotated, Any os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +import httpx import pytest from fastapi import FastAPI, Header from fastapi.testclient import TestClient @@ -47,6 +51,7 @@ from app.api.v1 import auth as auth_router from app.api.v1 import me as me_router from app.core import auth as auth_mod from app.core import auth_db, config +from app.core import password as password_mod from app.core.db import get_db from app.core.password import hash_password from app.core.rbac import rbac_guard @@ -254,6 +259,11 @@ def _build_test_app(store: _Store) -> FastAPI: def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: auth_mod.reset_cache_for_tests() auth_router._LOGIN_LIMITER._hits.clear() + auth_router._USERNAME_FAIL_LIMITER._hits.clear() + # Агрегатор отказов по насыщению (#2715) — тоже глобал процесса: без сброса + # недосчитанные отказы одного теста всплывают в записи другого. + monkeypatch.setattr(auth_router, "_saturation_rejected", 0) + monkeypatch.setattr(auth_router, "_saturation_reported_at", None) monkeypatch.setattr(config.settings, "auth_mode", "dual") # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже # если предыдущий переключался на `auth`. @@ -363,13 +373,16 @@ def test_login_always_calls_verify_password_timing_oracle_guard( store.add_user("nullhash", None, role="employee") calls: list[str] = [] - real_verify = auth_router.verify_password + real_verify = password_mod.verify_password def _counting_verify(plain: str, hashed: str) -> bool: calls.append(hashed) return real_verify(plain, hashed) - monkeypatch.setattr(auth_router, "verify_password", _counting_verify) + # Патчим тело в app.core.password, а не имя в auth: с #2665 хендлер зовёт + # `verify_password_bounded`, а та ищет `verify_password` в своём модуле на + # каждый вызов — так счётчик считает РЕАЛЬНЫЕ bcrypt-сверки, а не обёртку. + monkeypatch.setattr(password_mod, "verify_password", _counting_verify) resp_unknown = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"}) assert resp_unknown.status_code == 401 @@ -405,6 +418,746 @@ def test_login_rate_limit_429(client: TestClient, store: _Store) -> None: assert "Retry-After" in resp.headers +# --------------------------------------------------------------------------- +# #2571 — глобальный потолок попыток НА ИМЯ (распределённый перебор) +# --------------------------------------------------------------------------- + + +def _throttle_settings( + monkeypatch: pytest.MonkeyPatch, *, threshold: int, max_delay_s: float +) -> None: + """Порог и потолок задержки под тест: реальные 20/час и 8с ждать некогда.""" + monkeypatch.setattr(config.settings, "login_username_fail_threshold", threshold) + monkeypatch.setattr(config.settings, "login_username_throttle_max_delay_s", max_delay_s) + + +def _capture_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw)) + return events + + +def _failed_login_delays(events: list[dict[str, Any]]) -> list[float]: + return [e["payload"]["throttle_delay_s"] for e in events if e["event_type"] == "login_failed"] + + +def test_throttle_delay_grows_and_caps(monkeypatch: pytest.MonkeyPatch) -> None: + """Задержка = 0 до порога, дальше удваивается от 1с и упирается в потолок. + + Потолок обязателен: без него растущая задержка вырождается в ту самую + блокировку учётки, которой #2571 сознательно избегает (DoS против человека). + """ + _throttle_settings(monkeypatch, threshold=3, max_delay_s=4.0) + + assert auth_router._throttle_delay_s(1) == 0.0 + assert auth_router._throttle_delay_s(3) == 0.0 # ровно порог — ещё не перебор + assert auth_router._throttle_delay_s(4) == 1.0 + assert auth_router._throttle_delay_s(5) == 2.0 + assert auth_router._throttle_delay_s(6) == 4.0 + assert auth_router._throttle_delay_s(7) == 4.0 # потолок + assert auth_router._throttle_delay_s(1000) == 4.0 + # Счётчик ничем не ограничен сверху (`record()` только добавляет метку), а + # `min()` вычисляет ОБА аргумента. Без зажатого показателя степени + # `float(2 ** (excess - 1))` при ~1045 неудачах падает с OverflowError, и + # защита начинает отдавать 500 без задержки и без аудита — ровно под той + # нагрузкой, ради которой писалась. 1000 выше проходило впритык под обрывом. + assert auth_router._throttle_delay_s(5_000) == 4.0 + assert auth_router._throttle_delay_s(10**6) == 4.0 + + +def test_distributed_bruteforce_one_username_many_ips_hits_global_ceiling( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 1: перебор одного имени с РАЗНЫХ адресов упирается в потолок на имя. + + Каждая попытка приходит со своего IP, поэтому per-(username, IP) лимит + (`_LOGIN_LIMITER`, 5/300с) не срабатывает НИ РАЗУ — ровно та дыра, из-за + которой issue заведена. Ловит её только глобальный счётчик на имя. + """ + store.add_user("victim", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=3, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + for i in range(6): + resp = client.post( + "/api/v1/auth/login", + json={"username": "victim", "password": f"guess{i}"}, + headers={"x-forwarded-for": f"10.0.0.{i}"}, + ) + # per-IP бюджет не исчерпан ни на одном адресе — никаких 429. + assert resp.status_code == 401, resp.text + + delays = _failed_login_delays(events) + assert len(delays) == 6 + # Первые три — в пределах порога, дальше замедление. + assert delays[:3] == [0.0, 0.0, 0.0] + assert all(d > 0 for d in delays[3:]) + # Счётчик глобальный: считает попытки со ВСЕХ адресов, а не по каждому свой. + fails = [e["payload"]["username_fails_in_window"] for e in events] + assert fails == [1, 2, 3, 4, 5, 6] + + +def test_throttle_actually_delays_the_response( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Задержка именно ЖДЁТСЯ, а не только пишется в аудит. + + Отдельный тест, потому что все остальные читают величину задержки из + события: без этой проверки реализация, которая посчитала задержку и забыла + `await`, оставила бы весь набор зелёным. + """ + store.add_user("slowpoke", hash_password("Secret123!"), role="employee") + # threshold=0 → тормозим уже первую неудачу; потолок 1с — цена одного теста. + _throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0) + + started = time.monotonic() + resp = client.post("/api/v1/auth/login", json={"username": "slowpoke", "password": "wrong"}) + elapsed = time.monotonic() - started + + assert resp.status_code == 401 + assert elapsed >= 1.0 + + +def test_db_connection_released_before_sleeping( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Соединение с БД возвращается в пул ДО сна, а не удерживается всю задержку. + + На проде `get_identity_db` в дефолтном режиме отдаёт ту же сессию, что + `get_db` (движок с QueuePool 5+10, pool_timeout=30), а `get_user_by_username` + оставляет её в открытой транзакции. Сон внутри этой области жизни держал бы + соединение занятым все 8с: ~15 одновременно спящих неудач выбирают пул + целиком, и дальше ЛЮБОЙ эндпоинт ждёт checkout 30с и падает — отказ в + обслуживании против всех, ради ухода от которого замедление и выбиралось + вместо блокировки. + + Проверяем порядком, а не мокой пула: если `close()` случился до сна, между + ним и концом ответа лежит вся задержка; если бы сессию закрывала только + зависимость (то есть после сна) — зазор был бы околонулевым. + """ + store.add_user("holder", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0) + + closes: list[float] = [] + real_close = _FakeDB.close + + def _spy_close(self: _FakeDB) -> None: + closes.append(time.monotonic()) + real_close(self) + + monkeypatch.setattr(_FakeDB, "close", _spy_close) + + resp = client.post("/api/v1/auth/login", json={"username": "holder", "password": "wrong"}) + finished = time.monotonic() + + assert resp.status_code == 401 + assert closes, "сессия не закрывалась вовсе" + assert finished - closes[0] >= 1.0 + + +def test_typo_does_not_throttle_and_correct_password_still_works( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 2: живой человек с опечаткой не наказан. + + Порог штатный (20/час), три опечатки до него не дотягивают — задержки нет, + а верный пароль пускает сразу же. Замедление применяется ТОЛЬКО к неудаче, + поэтому даже перебранный порог не мешает владельцу пароля войти. + """ + store.add_user("erin", hash_password("Secret123!"), role="employee") + events = _capture_events(monkeypatch) + + for _ in range(3): + resp = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123"}) + assert resp.status_code == 401 + + assert _failed_login_delays(events) == [0.0, 0.0, 0.0] + + ok = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123!"}) + assert ok.status_code == 200, ok.text + assert config.settings.session_cookie_name in ok.cookies + + +def test_counter_decays_when_window_passes(monkeypatch: pytest.MonkeyPatch) -> None: + """Вторая половина DoD 2: наказание не накапливается вечно. + + Окно скользящее, старые неудачи выпадают сами — снимать ничего вручную не + нужно. Проверяем на самом счётчике, а не через HTTP: один вызов login стоит + полного bcrypt (~0.25с), так что игрушечное окно истекало бы прямо посреди + цикла запросов и тест мерил бы скорость хеширования, а не спад счётчика. + """ + _throttle_settings(monkeypatch, threshold=1, max_delay_s=4.0) + limiter = auth_router._USERNAME_FAIL_LIMITER + monkeypatch.setattr(limiter, "_window_s", 0.2) + + assert [limiter.record("frank") for _ in range(3)] == [1, 2, 3] + assert auth_router._throttle_delay_s(3) > 0 + + time.sleep(0.25) # окно прошло — прошлые неудачи больше не считаются + + assert limiter.record("frank") == 1 + assert auth_router._throttle_delay_s(1) == 0.0 + + +def test_username_length_is_bounded(client: TestClient) -> None: + """Сырое имя становится ключом обоих лимитеров, а их словарь чистится только + при >10000 ключей и только от пустых корзин — при окне в час чистить нечего. + Границу длины держим на 64 (верх CHECK'а реестра), чтобы килобайтные имена + не растили память ключами.""" + resp = client.post("/api/v1/auth/login", json={"username": "x" * 65, "password": "p"}) + assert resp.status_code == 422 + # 64 — всё ещё валидная длина, отвечаем обычным generic-отказом. + ok_len = client.post("/api/v1/auth/login", json={"username": "x" * 64, "password": "p"}) + assert ok_len.status_code == 401 + + +def test_throttle_identical_for_existing_and_unknown_username( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Замедление не должно стать оракулом существования учётки. + + Если тормозить только существующие имена, злоумышленник перечисляет живые + логины по времени ответа, не зная ни одного пароля — та же user-enumeration, + от которой защищают одинаковый 401 и безусловный bcrypt (`_DUMMY_PASSWORD_HASH`), + только по другому каналу. Счётчик ведётся по присланному имени БЕЗ проверки + в реестре, поэтому обе последовательности обязаны совпасть. + """ + store.add_user("real", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=2, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + real_responses = [] + for i in range(4): + real_responses.append( + client.post( + "/api/v1/auth/login", + json={"username": "real", "password": "wrong"}, + headers={"x-forwarded-for": f"10.1.0.{i}"}, + ) + ) + real_delays = _failed_login_delays(events) + + events.clear() + ghost_responses = [] + for i in range(4): + ghost_responses.append( + client.post( + "/api/v1/auth/login", + json={"username": "ghost", "password": "wrong"}, + headers={"x-forwarded-for": f"10.2.0.{i}"}, + ) + ) + ghost_delays = _failed_login_delays(events) + + assert real_delays == ghost_delays + assert real_delays[:2] == [0.0, 0.0] and all(d > 0 for d in real_delays[2:]) + # И «наблюдаемая» часть ответа тоже неотличима. + assert [r.status_code for r in real_responses] == [r.status_code for r in ghost_responses] + assert {r.json()["detail"] for r in real_responses} == { + r.json()["detail"] for r in ghost_responses + } + + +def test_disabled_account_throttled_like_wrong_password( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Отказ по «доступ закрыт» идёт тем же хвостом: счётчик, аудит, задержка. + + Иначе быстрый 401 у заблокированной учётки отличал бы её от несуществующей. + """ + store.add_user( + "blocked", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED + ) + _throttle_settings(monkeypatch, threshold=1, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + for i in range(2): + # Пароль ВЕРНЫЙ — отказ приходит именно из ветки состояния доступа. + resp = client.post( + "/api/v1/auth/login", + json={"username": "blocked", "password": "Secret123!"}, + headers={"x-forwarded-for": f"10.3.0.{i}"}, + ) + assert resp.status_code == 401 + + assert _failed_login_delays(events) == [0.0, pytest.approx(0.01)] + + +def test_failed_login_events_reach_audit_with_counter_state( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 3: неудачные входы видны в аудите — с ip/ua и состоянием счётчика. + + Именно счётчик отличает распределённый перебор от россыпи случайных + одиночных неудач: в `user_events` иначе видно только «по одной неудаче с + каждого адреса», что само по себе не тревожно. + """ + store.add_user("audited", hash_password("Secret123!"), role="employee") + events = _capture_events(monkeypatch) + + resp = client.post( + "/api/v1/auth/login", + json={"username": "audited", "password": "s3cret-typo"}, + # Левый хоп подделан клиентом, правый добавлен Caddy — в аудит обязан + # попасть ПРАВЫЙ (`ratelimit._client_ip`, допущение «ровно один прокси»). + # Иначе злоумышленник переписывал бы себе ip в журнале одним заголовком. + headers={ + "x-forwarded-for": "198.51.100.9, 203.0.113.7", + "user-agent": "probe/1.0", + }, + ) + + assert resp.status_code == 401 + (failed,) = (e for e in events if e["event_type"] == "login_failed") + assert failed["username"] == "audited" + assert failed["ip"] == "203.0.113.7" + assert failed["user_agent"] == "probe/1.0" + assert failed["path"] == "/api/v1/auth/login" + assert failed["payload"]["username_fails_in_window"] == 1 + # Raw-пароль не утекает ни в поля события, ни в payload. + assert "s3cret-typo" not in str(failed) + + +# --------------------------------------------------------------------------- +# #2665 — настоящий потолок ТЕМПА проверок пароля + свободный событийный цикл +# --------------------------------------------------------------------------- + + +async def test_login_flood_capped_by_rate_while_api_stays_responsive( + store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Сто одновременных соединений не получают больше N попыток В СЕКУНДУ, и при + этом остальной API продолжает отвечать. + + ОБА утверждения в одном тесте намеренно — по отдельности каждое зелено на + сломанной системе: + - только про темп: сегодняшний код (bcrypt прямо в `async def`) тоже + держит темп низким — ценой того, что весь API стоит; + - только про отзывчивость: `asyncio.to_thread` без потолка освобождает + цикл и одновременно РАЗГОНЯЕТ перебор (замер на проде: 3.6 → 16 + проверок/с). + Убери любую половину правки — тест обязан покраснеть. + + Проверяем ТЕМП, а не латентность: задержка из #2571 (`await asyncio.sleep`) + латентность растит, а темп не ограничивает вовсе — сто соединений отспят её + параллельно. Поэтому меряем ЧИСЛО состоявшихся bcrypt-сверок за секунду + непрерывного флуда, а не время одного ответа. + + Каждый запрос идёт со СВОЕЙ парой (username, ip). Это худший случай для + защит #2571 и он же реалистичный: при credential stuffing ни лимит на + (username, IP), ни счётчик неудач на имя не срабатывают ни разу — с чужого + адреса и с новым именем бюджет всегда свежий. Значит меряем ровно новый + потолок, а не соседний лимитер. + """ + verify_s = 0.05 + # Потолок = размер пула / время одной сверки. Значение берётся из ТОЙ ЖЕ + # настройки, что его задаёт, поэтому этот тест проверяет только МЕХАНИКУ + # (потолок работает и равен пулу), но НЕ величину дефолта: подними + # login_password_verify_workers — поднимется и ожидание, тест останется + # зелёным. Сам дефолт стережёт + # tests/test_password.py::test_verify_ceiling_defaults_stay_within_the_db_pool. + ceiling_per_s = config.settings.login_password_verify_workers / verify_s + + patch_identity_sessions(monkeypatch, lambda: _FakeDB(store)) + _capture_events(monkeypatch) + app = _build_test_app(store) + + attempts: list[float] = [] + + def _slow_verify(plain: str, hashed: str) -> bool: + """Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше. + + Блокирующий `time.sleep`, а не `await` — суть проблемы в том, что bcrypt + не отпускает поток; двойник с `await` проверял бы не то. + """ + attempts.append(time.monotonic()) + time.sleep(verify_s) + return False + + monkeypatch.setattr(password_mod, "verify_password", _slow_verify) + + probe_latencies: list[float] = [] + flood_over = asyncio.Event() + + async def probe(client: httpx.AsyncClient) -> None: + """Сторонний (не login) запрос раз в 10мс — детектор занятости цикла.""" + while not flood_over.is_set(): + t0 = time.monotonic() + await client.get("/api/v1/trade-in/dummy") + probe_latencies.append(time.monotonic() - t0) + await asyncio.sleep(0.01) + + duration_s = 1.0 + connections = 100 + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://testserver" + ) as client: + + async def attacker(n: int) -> list[int]: + codes: list[int] = [] + i = 0 + while time.monotonic() < deadline: + i += 1 + resp = await client.post( + "/api/v1/auth/login", + json={"username": f"spray{n}x{i}", "password": "guess"}, + headers={"x-forwarded-for": f"10.{n % 250}.{i % 250}.7"}, + ) + codes.append(resp.status_code) + await asyncio.sleep(0.005) + return codes + + started = time.monotonic() + deadline = started + duration_s + probe_task = asyncio.create_task(probe(client)) + code_lists = await asyncio.gather(*(attacker(n) for n in range(connections))) + elapsed = time.monotonic() - started + flood_over.set() + await probe_task + + codes = [c for lst in code_lists for c in lst] + attempts_per_s = len(attempts) / elapsed + + # 1. Событийный цикл СВОБОДЕН всё это время. С bcrypt внутри `async def` + # сторонний запрос ждёт столько, сколько длится очередь сверок. + # Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда + # остальные числа мерят не потолок, а паралич — их надо читать после + # этого вердикта, а не вместо него. + assert probe_latencies, "проба не сделала ни одного запроса" + probe_latencies.sort() + assert ( + probe_latencies[-1] < 0.5 + ), f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа" + median_probe = probe_latencies[len(probe_latencies) // 2] + assert median_probe < verify_s, ( + f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной " + f"сверки — цикл занят проверкой пароля, API стоит" + ) + # Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс. + assert ( + len(probe_latencies) >= 10 + ), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят" + + # 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает + # потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик). + max_attempts_per_s = ceiling_per_s * 1.5 + offered_per_s = len(codes) / elapsed + + # ПРЕДУСЛОВИЕ, и оно отделено от вердикта намеренно: «нагрузку создать не + # удалось» и «потолок не работает» — разные новости, и красный обязан их + # различать. Порог здесь ТОТ ЖЕ, с которым сверяется вердикт ниже, и это не + # совпадение: пока флуд предлагает меньше, следующее утверждение зелено даже + # на системе вовсе без потолка, то есть измерения нет. + # + # Раньше условием было `len(codes) > connections` — «каждое соединение + # успело сходить хотя бы дважды за секунду». Это мерило скорости РАННЕРА, а + # не нагрузки: на занятом первый круг из ста запросов сам съедал всю секунду, + # и сторож краснел неотличимо от настоящей поломки потолка (замер 2026-08-07: + # свободная машина — 0 падений из 20, ~800 ответов/с; под load average ~185 — + # 10 падений из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с + # против порога 30/с, то есть мерить было на чем). + assert offered_per_s > max_attempts_per_s, ( + f"нагрузку создать не удалось: флуд предложил {offered_per_s:.0f} запросов/с " + f"({len(codes)} за {elapsed:.2f}с) — не больше порога следующей проверки " + f"({max_attempts_per_s:.0f}/с), она прошла бы и без потолка. Это «раннер не " + f"потянул», НЕ «потолок сломан»" + ) + assert attempts_per_s <= max_attempts_per_s, ( + f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с " + f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает" + ) + # 3. Лишнее ОТКЛОНЯЕТСЯ, а не копится в очереди: очередь держала бы + # соединения к БД и выбрала бы пул (QueuePool 5+10). + assert codes.count(429) > codes.count(401), "избыток должен получать 429, а не ждать" + # 4. Потолок режет ТЕМП, а не обнуляет попытки: до bcrypt доезжает хоть + # что-то. Это утверждение ПРО АТАКУЮЩЕГО и ни слова не говорит о том, + # войдёт ли в это время живой человек — он не войдёт, пока слоты заняты + # флудом (#2714). Проверка «дверь открыта своим» — отдельным тестом ниже, + # и мерит она вход С ДРУГОГО КЛЮЧА, а не число попыток атакующего. + assert len(attempts) >= 2 + + +# --------------------------------------------------------------------------- +# #2714 — потолок не должен бить по своим: доля слотов на один ключ +# --------------------------------------------------------------------------- + + +async def test_flood_from_one_ip_leaves_login_open_for_another_ip( + store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Пока один адрес непрерывно флудит, человек с ДРУГОГО адреса входит (#2714). + + Именно это ломал потолок #2665 в исходном виде: слоты — общий котёл, флуд + занимал все четыре, и легитимный вход с ВЕРНЫМ паролем получал 429 столько + раз, сколько пытался (замер в issue: 20 попыток → 20×429, 0×200). + + Тест мерит ровно заявленное — ВХОД С ДРУГОГО КЛЮЧА, а не латентность одного + запроса и не число попыток атакующего: с общим котлом «попытки атакующего + доезжают» и «свой войдёт» — разные утверждения, и первое зелено, когда + второе ложно. + + Число попыток пробы = `login_rate_limit`, и это не подгонка: столько входов + по паре (имя, IP) вообще разрешено за окно соседним `_LOGIN_LIMITER`. + Просить больше значило бы мерить ЕГО 429 вместо потолка сверок — то есть + получить красный тест на исправном коде. + + Стенд БЕЗ `RateLimitMiddleware` (его в тестовом приложении нет), поэтому + флуд здесь плотнее, чем один адрес может выдать на проде (там его режут + 300 запросов за 60с). Так и задумано: проверяем худший случай. + """ + verify_s = 0.05 + flood_ip = "203.0.113.66" + legit_ip = "198.51.100.10" + legit_password = "Secret123!" + + patch_identity_sessions(monkeypatch, lambda: _FakeDB(store)) + _capture_events(monkeypatch) + legit_hash = hash_password(legit_password) + store.add_user("realuser", legit_hash, role="employee") + app = _build_test_app(store) + + def _slow_verify(plain: str, hashed: str) -> bool: + """Двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше. + + Вердикт настоящий (а не всегда-False, как в тесте про темп выше) — без + него легитимный вход не дошёл бы до 200 и мерить было бы нечего. + """ + time.sleep(verify_s) + return hashed == legit_hash and plain == legit_password + + monkeypatch.setattr(password_mod, "verify_password", _slow_verify) + + flood_codes: list[int] = [] + flood_over = asyncio.Event() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://testserver" + ) as client: + + async def flooder(n: int) -> None: + i = 0 + while not flood_over.is_set(): + i += 1 + # Своё имя на каждую попытку — иначе флуд упрётся в + # `_LOGIN_LIMITER` (5 на пару имя+IP) и до потолка сверок не + # доедет вовсе: тест стал бы зелёным, ничего не проверив. + resp = await client.post( + "/api/v1/auth/login", + json={"username": f"nosuchuser{n}x{i}", "password": "guess"}, + headers={"x-forwarded-for": flood_ip}, + ) + flood_codes.append(resp.status_code) + + floods = [asyncio.create_task(flooder(n)) for n in range(8)] + try: + # Ждём ДОКАЗАННОГО насыщения: 429 у атакующего = слоты кончились. + # Без этого условия проба могла бы пройти по пустой очереди и тест + # был бы зелёным на сломанном коде. + saturation_deadline = time.monotonic() + 10.0 + while flood_codes.count(429) < 4: + assert time.monotonic() < saturation_deadline, ( + f"флуд не насытил слоты за 10с ({len(flood_codes)} ответов, " + f"429: {flood_codes.count(429)}) — мерить справедливость не на чем" + ) + await asyncio.sleep(0.01) + + probe_codes: list[int] = [] + for _ in range(config.settings.login_rate_limit): + resp = await client.post( + "/api/v1/auth/login", + json={"username": "realuser", "password": legit_password}, + headers={"x-forwarded-for": legit_ip}, + ) + probe_codes.append(resp.status_code) + finally: + flood_over.set() + await asyncio.gather(*floods) + + assert probe_codes.count(200) == len(probe_codes), ( + f"легитимный вход с {legit_ip} во время флуда с {flood_ip}: " + f"200={probe_codes.count(200)}, 429={probe_codes.count(429)}, " + f"401={probe_codes.count(401)} — потолок бьёт по своим (#2714)" + ) + + +# --------------------------------------------------------------------------- +# #2715 — отказ по насыщению: до похода в БД и со следом, который не выселяет лог +# --------------------------------------------------------------------------- + + +def _saturate_verify_slots(monkeypatch: pytest.MonkeyPatch) -> None: + """Слоты сверки заняты — снаружи ровно то же, что живой флуд, но без гонок. + + Именно счётчик, а не мок `verify_password_bounded`: проверяем настоящий + предикат отказа (`verify_slots_saturated` читает этот же глобал), а не + собственную заглушку. + """ + monkeypatch.setattr(password_mod, "_verify_inflight", 999) + + +def test_saturated_login_answers_before_touching_the_registry( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Под насыщением отказ приходит ДО выборки пользователя (#2715). + + Гейт стоял после SELECT'а, и каждый заведомо отклоняемый запрос всё равно + брал соединение из пула — тогда, когда система уже перегружена. Хуже того, + эта выборка оставалась ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, ровняющий + время ответа для существующего и несуществующего имени, до отказанного + запроса не доходит вовсе, так что разницу маскировать было нечем. + + Мерим не тайминг (в CI флейкует), а сам факт похода в реестр — и заодно + побайтовую одинаковость ответа для живого и выдуманного имени. + """ + store.add_user("alice", hash_password("Secret123!"), role="employee") + _capture_events(monkeypatch) + + lookups: list[str] = [] + real_lookup = auth_router.get_user_by_username + + def _spy(db: Any, username: str) -> Any: + lookups.append(username) + return real_lookup(db, username) + + monkeypatch.setattr(auth_router, "get_user_by_username", _spy) + _saturate_verify_slots(monkeypatch) + + bodies = [] + for name in ("alice", "ghost"): + resp = client.post( + "/api/v1/auth/login", + json={"username": name, "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429, resp.text + assert resp.headers["Retry-After"] == "1" + bodies.append(resp.text) + + assert lookups == [], f"под насыщением всё-таки сходили в реестр: {lookups}" + # Существующее и несуществующее имя — неразличимы (#2571 на этом пути тоже). + assert bodies[0] == bodies[1] + + +def test_key_share_alone_also_answers_before_the_registry( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Долю на ключ предчек проверяет ТОЖЕ — и это главный случай, а не запасной. + + Соседний тест занимает ОБЩИЙ счётчик, а `or` в `verify_slots_saturated` + коротит на первой половине: выброси вторую — и тот тест останется зелёным. + Между тем при флуде с ОДНОГО адреса (#2714) общий потолок не выбирается + вовсе, первой упирается именно доля, и без неё в базу ходили бы почти все + отклонённые запросы. + """ + store.add_user("alice", hash_password("Secret123!"), role="employee") + _capture_events(monkeypatch) + + lookups: list[str] = [] + real_lookup = auth_router.get_user_by_username + + def _spy(db: Any, username: str) -> Any: + lookups.append(username) + return real_lookup(db, username) + + monkeypatch.setattr(auth_router, "get_user_by_username", _spy) + # Общий котёл (4) НЕ выбран: занято 2 из 4, и оба — одним адресом. Это ровно + # его доля (`_per_key_slot_cap` = 4 // 2), больше ему не дают. + monkeypatch.setattr(password_mod, "_verify_inflight", 2) + monkeypatch.setattr(password_mod, "_verify_inflight_by_key", {"203.0.113.5": 2}) + assert password_mod._per_key_slot_cap() == 2 # исходные условия теста + + flooder = client.post( + "/api/v1/auth/login", + json={"username": "alice", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert flooder.status_code == 429, flooder.text + assert lookups == [], f"доля исчерпана, а в реестр всё-таки сходили: {lookups}" + + # И тут же — доказательство, что предчек не отказывает всем подряд: с + # ДРУГОГО адреса свободные слоты есть, запрос идёт дальше, в реестр. + other = client.post( + "/api/v1/auth/login", + json={"username": "alice", "password": "wrong"}, + headers={"x-forwarded-for": "198.51.100.10"}, + ) + assert other.status_code == 401, other.text + assert lookups == ["alice"] + + +def test_saturation_is_reported_once_per_window_and_lands_in_audit( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch, caplog: Any +) -> None: + """Двадцать отказов — одна запись в лог и одно событие в аудит, со счётчиком. + + Строка на каждый отказ делила с остальным бэкендом `json-file max-size 20m × + max-file 3`: при флуде в сотни запросов в секунду 60 МБ прокручиваются за + минуты и выселяют ВСЕ остальные логи ровно во время атаки. Поэтому окно. + + А событие в `user_events` — потому что до #2715 инцидент не оставлял в + аудите ни строчки: событие неудачного входа тут не пишется намеренно + (пароль не проверялся, и трата бюджета неудач дала бы блокировку чужой + учётки насыщением) — значит нужен отдельный тип события, и он обязан + появляться независимо от того, ротировался лог или нет. + """ + # ЛИТЕРАЛ, а не арифметика от настройки: окно — компромисс «видно вовремя» + # против «не выселяет лог», и подъём его до минут прячет атаку целиком. + assert auth_router._SATURATION_REPORT_WINDOW_S == 1.0 + + events = _capture_events(monkeypatch) + _saturate_verify_slots(monkeypatch) + # Окно на весь тест — иначе медленный CI разбил бы 20 запросов на два окна + # и число записей стало бы функцией скорости раннера. + monkeypatch.setattr(auth_router, "_SATURATION_REPORT_WINDOW_S", 60.0) + caplog.set_level(logging.WARNING, logger="app.api.v1.auth") + + for i in range(20): + resp = client.post( + "/api/v1/auth/login", + json={"username": f"ghost{i}", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429, resp.text + + lines = [r for r in caplog.records if "saturated" in r.getMessage()] + assert len(lines) == 1, f"20 отказов дали {len(lines)} строк в логе — агрегации нет" + # ERROR, а не WARNING: бэкенд поднят с LoggingIntegration(event_level=ERROR) + # (app/main.py), и только с ERROR запись становится событием GlitchTip. + # Понижение уровня выключило бы канал молча — прецедент #2674. + assert lines[0].levelno == logging.ERROR + + saturated = [e for e in events if e["event_type"] == "login_verify_saturated"] + assert len(saturated) == 1, saturated + # Первый отказ отчитывается сразу (одиночная аномалия обязана быть видна), + # поэтому в первой записи он один — накопленное придёт следующей. + assert saturated[0]["payload"] == {"rejected": 1, "since_prev_s": None} + assert saturated[0]["ip"] == "203.0.113.5" + # Имя не пишем: отказ случился ДО того, как мы на него посмотрели, а запись + # присланного дала бы атакующему аудит-строки с любым именем на выбор. + assert saturated[0]["username"] == "" + + # Бюджет неудач по имени не тронут — иначе насыщением блокируют чужой вход. + assert [e for e in events if e["event_type"] == "login_failed"] == [] + assert not auth_router._USERNAME_FAIL_LIMITER._hits + + # Окно прошло — следующий отказ приносит НАКОПЛЕННОЕ, а не единицу. + monkeypatch.setattr(auth_router, "_saturation_reported_at", time.monotonic() - 61.0) + resp = client.post( + "/api/v1/auth/login", + json={"username": "ghost-last", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429 + saturated = [e for e in events if e["event_type"] == "login_verify_saturated"] + assert len(saturated) == 2 + assert saturated[1]["payload"]["rejected"] == 20, "счётчик за окно потерян" + # Без этого числа 20 отказов читались бы как «20 за секунду», хотя копились + # они минуту: хвост уезжает в запись, датированную моментом СЛЕДУЮЩЕГО + # отказа и подписанную ЕГО адресом — возможно, случайного соседа по NAT. + assert saturated[1]["payload"]["since_prev_s"] == pytest.approx(61.0, abs=1.0) + + # --------------------------------------------------------------------------- # POST /logout # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_backfill_honest_status.py b/tradein-mvp/backend/tests/test_backfill_honest_status.py new file mode 100644 index 00000000..ad72db34 --- /dev/null +++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py @@ -0,0 +1,84 @@ +"""#2674 — detail-backfill с нулём обогащений перестаёт называться 'done'. + +Все три backfill'а (avito/yandex/domclick) финализировались одним mark_done. +На проде 2026-08-06 это 78 прогонов из 158: avito 23/76 (включая 5 прогонов на +1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30 +(494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done'). + +Проверяем ровно ветвление mark_backfill_finished — БД замокана. +""" + +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 app.services import scrape_runs as runs_mod + + +def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, str | None]: + """Вызвать mark_backfill_finished с замоканными mark_* → (статус, причина).""" + calls: list[tuple[str, str | None]] = [] + with ( + patch.object(runs_mod, "mark_done", lambda *a, **k: calls.append(("done", None))), + patch.object( + runs_mod, "mark_failed", lambda db, rid, err, c: calls.append(("failed", err)) + ), + # **k — mark_banned принимает ещё и ban_kind (#2686/#2764); этот тест про + # ВЫБОР финализатора, диагноз проверяет test_2764_ban_kind_no_default.py. + patch.object( + runs_mod, "mark_banned", lambda db, rid, err, c, **k: calls.append(("banned", err)) + ), + ): + runs_mod.mark_backfill_finished( + MagicMock(), + 1, + counters, + source="domclick_detail_backfill", + aborted_by_blocks=aborted, + ) + assert len(calls) == 1, f"ожидался ровно один финализатор, получено {calls}" + return calls[0] + + +@pytest.mark.parametrize( + ("counters", "aborted", "expected"), + [ + # Прод-факт domclick: 16 прогонов attempted=3 blocked=3 → брейкер оборвал. + ({"attempted": 3, "enriched": 0, "blocked": 3, "failed": 0}, True, "banned"), + # Прод-факт domclick: 100 попыток, все fail, блоки не распознаны (до #2645). + ({"attempted": 100, "enriched": 0, "blocked": 0, "failed": 100}, False, "failed"), + # Прод-факт avito: 1500 попыток, 1499 fail + 1 блок, ноль обогащений. + ({"attempted": 1500, "enriched": 0, "blocked": 1, "failed": 1499}, False, "banned"), + # Прод-факт yandex: 31 прогон attempted=5 failed=5, ключа blocked нет вовсе. + ({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"), + # Кандидатов не было — честная пустота, это успех. + ({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"), + # Частичный прогон: обогатили хоть что-то → успех. + ({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"), + # Блоки были, но прогон доработал и обогатил — не бан. + ({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"), + # Блок оборвал прогон, хотя часть успели обогатить — работа не доделана. + ({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"), + # avito: 404-«снято с продажи» — тоже результат, а не пустой прогон. + ({"attempted": 30, "enriched": 0, "gone": 30, "blocked": 0, "failed": 0}, False, "done"), + ], +) +def test_status_matches_reality(counters: dict[str, Any], aborted: bool, expected: str) -> None: + status, _ = _finish(counters, aborted=aborted) + assert status == expected + + +def test_reason_carries_numbers_and_marker() -> None: + """Причина в scrape_runs.error должна быть читаемой человеком, не пустой.""" + status, reason = _finish({"attempted": 3, "enriched": 0, "blocked": 3}, aborted=True) + assert status == "banned" + assert reason is not None + assert "backfill-honest-status" in reason + assert "domclick_detail_backfill" in reason + assert "blocked=3" in reason and "из 3 попыток" in reason diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 8aaefd7b..5ac78324 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -367,23 +367,28 @@ class TestHouseTypeMap: assert _map_house_type("brick") == "brick" assert _map_house_type("monolith") == "monolithic" assert _map_house_type("monolith_brick") == "monolithic" - assert _map_house_type("monolithic") == "monolithic" assert _map_house_type("block") == "block" assert _map_house_type("wood") == "wood" - def test_unknown_falls_back_to_panel(self): + def test_unknown_is_none_not_panel(self): + """#2674: дефолт 'panel' убран — он занижал оценку. Неизвестное → None. + + Полное покрытие camelCase-вокабуляра и skip-пути: + tests/test_house_imv_params_honesty.py. + """ from app.services.house_imv_backfill import _map_house_type - assert _map_house_type("unknown_type") == "panel" - assert _map_house_type(None) == "panel" - assert _map_house_type("") == "panel" + assert _map_house_type("unknown_type") is None + assert _map_house_type(None) is None + assert _map_house_type("") is None def test_case_insensitive(self): from app.services.house_imv_backfill import _map_house_type assert _map_house_type("PANEL") == "panel" assert _map_house_type("Brick") == "brick" - assert _map_house_type("MONOLITH_BRICK") == "monolithic" + # SCREAMING-вокабуляр Яндекса (MONOLIT_BRICK, одна «т») — реальный токен. + assert _map_house_type("MONOLIT_BRICK") == "monolithic" class TestRegionPrefix: diff --git a/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py b/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py new file mode 100644 index 00000000..2ae59653 --- /dev/null +++ b/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py @@ -0,0 +1,193 @@ +"""#2699 — высота потолков сведена к одной колонке listings.ceiling_height_m. + +Что было (прод-замер 2026-08-07, 94 373 объявления): + +* ``ceiling_height`` (мигр. 019, numeric(3,2)) — 8 554 значения: cian 855, + yandex 7 699. ``ceiling_height_m`` (мигр. 111, numeric(5,2)) — 14 824: + avito 7 149, yandex 7 675. +* Пересечение 7 675 строк, РАСХОЖДЕНИЙ НОЛЬ — одно и то же значение в двух + ящиках, а не два конфликтующих источника. +* Эстиматор (``_apply_kitchen_ceiling_signal``) читает только ``_m`` → 855 + циановских + 24 яндексовых значения не видит никто. +* Гейт правдоподобия был только у yandex SERP → avito detail нагнал 26 значений + > 6 м (максимум 29.90) и 83 ровных 0.00. + +Каждый тест ниже КРАСНЫЙ на коде до #2699. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from unittest.mock import MagicMock + +import pytest +from scraper_kit.ceiling_height import plausible_ceiling_m +from scraper_kit.providers.avito.detail import _parse_height_m +from scraper_kit.providers.cian.detail import DetailEnrichment as CianEnrichment +from scraper_kit.providers.cian.detail import save_detail_enrichment as cian_save +from scraper_kit.providers.yandex.detail import DetailEnrichment as YandexEnrichment +from scraper_kit.providers.yandex.detail import save_detail_enrichment as yandex_save + +from app.services.matching.conflict_resolution import ( + HOUSE_FIELD_PRIORITY, + LISTING_FIELD_PRIORITY, +) + +# ── гейт правдоподобия ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (2.7, 2.7), # типовая панелька + (2.0, 2.0), # нижняя граница включительно + (6.0, 6.0), # верхняя граница включительно (двухуровневая карточка) + (5.97, 5.97), # реальный максимум cian на проде + ("2,7".replace(",", "."), 2.7), # строковый вход от парсера + (29.90, None), # прод-максимум мусора avito + (18.0, None), # мусор yandex SERP из #2007 + (0.0, None), # 83 строки avito на проде + (1.70, None), # ниже жилого потолка + (9.99, None), # влезает в numeric(3,2), но потолком не является + (None, None), + ("не число", None), + ], +) +def test_plausible_ceiling_m_band(raw: object, expected: float | None) -> None: + assert plausible_ceiling_m(raw) == expected # type: ignore[arg-type] + + +def test_avito_parse_height_rejects_impossible() -> None: + """КОРЕНЬ мусора: парсер avito брал первое число строки без проверки. + + Красный до #2699: возвращал 29.9 / 0.0 и они уезжали в ceiling_height_m, + который читает эстиматор. + """ + assert _parse_height_m("2,7 м") == pytest.approx(2.7) + assert _parse_height_m("3 м") == pytest.approx(3.0) + assert _parse_height_m("29,9 м") is None + assert _parse_height_m("0 м") is None + assert _parse_height_m("нет данных") is None + + +# ── писатели пишут В КАНОН ─────────────────────────────────────────────────── + + +def _mock_db() -> MagicMock: + """Session-заглушка, копящая (sql, params) каждого execute.""" + db = MagicMock() + calls: list[tuple[str, dict]] = [] + + @contextmanager + def _nested(): # type: ignore[no-untyped-def] + yield MagicMock() + + db.begin_nested.side_effect = _nested + + def _execute(sql, params=None): # type: ignore[no-untyped-def] + calls.append((str(sql), params or {})) + result = MagicMock() + result.fetchone.return_value = None + result.scalar_one_or_none.return_value = None + result.rowcount = 1 + return result + + db.execute.side_effect = _execute + db._calls = calls + return db + + +def _yandex_enrichment(ceiling_height: float) -> YandexEnrichment: + return YandexEnrichment( + offer_id="1234567890", + source_url="https://realty.yandex.ru/offer/1234567890/", + ceiling_height=ceiling_height, + ) + + +def _listings_update_sql(db: MagicMock) -> str: + for sql, _params in db._calls: + if "UPDATE listings" in sql: + return sql + raise AssertionError("не нашли UPDATE listings") + + +def test_cian_detail_writes_canonical_column() -> None: + """855 значений Циана уходили в колонку, которую эстиматор не читает. + + Красный до #2699: UPDATE присваивал `ceiling_height`, не `ceiling_height_m`. + """ + db = _mock_db() + cian_save(db, 55, CianEnrichment(ceiling_height=2.7)) + + sql = _listings_update_sql(db) + assert "ceiling_height_m = COALESCE" in sql + assert "ceiling_height = COALESCE" not in sql + + +def test_yandex_detail_writes_canonical_column() -> None: + """Красный до #2699: yandex detail тоже писал только в 019-колонку.""" + db = _mock_db() + yandex_save(db, 77, _yandex_enrichment(2.55)) + + sql = _listings_update_sql(db) + assert "ceiling_height_m = COALESCE" in sql + assert "ceiling_height = COALESCE" not in sql + + +def test_detail_writers_gate_impossible_values() -> None: + """Невозможная высота не доезжает до БД ни одним из detail-путей.""" + db_cian = _mock_db() + cian_save(db_cian, 55, CianEnrichment(ceiling_height=29.9)) + assert next(p for s, p in db_cian._calls if "UPDATE listings" in s)["ch"] is None + + db_yandex = _mock_db() + yandex_save(db_yandex, 77, _yandex_enrichment(0.0)) + assert next(p for s, p in db_yandex._calls if "UPDATE listings" in s)["ceiling_height"] is None + + +def test_base_upsert_no_longer_writes_deprecated_column() -> None: + """save_listings писал ОДИН param в ОБЕ колонки — источник дубля. + + Красный до #2699: SQL содержал `ceiling_height,` в списке колонок INSERT. + Проверяем по исходнику модуля: собрать SQL иначе можно только выполнив + upsert против живой БД. + """ + import re + from pathlib import Path + + import scraper_kit.base as base + + src = Path(base.__file__).read_text(encoding="utf-8") + # SQL-упоминания колонки: `ceiling_height` не как префикс `ceiling_height_m` + # и не внутри комментария (`--`). + offenders = [ + line + for line in src.splitlines() + # `ceiling_height` в позиции SQL-колонки: перед `,` (список INSERT) или + # перед `=` (SET). Импорт модуля-гейта и комментарии не считаются. + if re.search(r"\bceiling_height\b(?!_m)\s*[,=]", line) and "--" not in line + ] + assert offenders == [], f"base.py всё ещё пишет DEPRECATED-колонку: {offenders}" + + +# ── карта приоритетов больше не указывает в пустоту ────────────────────────── + + +def test_house_priority_has_no_phantom_ceiling_rule() -> None: + """Колонки `ceiling_height` в таблице houses нет и не было. + + Красный до #2699: правило существовало и не могло сработать ни разу. + """ + assert not [k for k in HOUSE_FIELD_PRIORITY if "ceiling" in k] + + +def test_listing_priority_names_the_real_column() -> None: + """Красный до #2699: ключ назывался `ceiling_height` — колонка есть, но её + не читает ни один потребитель, влияющий на цену.""" + assert "ceiling_height" not in LISTING_FIELD_PRIORITY + assert LISTING_FIELD_PRIORITY["ceiling_height_m"] == [ + "cian_detail", + "avito_detail", + "yandex_detail", + ] diff --git a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py index e41a2790..1210e5ec 100644 --- a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py +++ b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py @@ -21,9 +21,13 @@ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from contextlib import contextmanager -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock -from scraper_kit.providers.cian.detail import DetailEnrichment, save_detail_enrichment +from scraper_kit.providers.cian.detail import ( + DetailEnrichment, + fetch_detail, + save_detail_enrichment, +) from scraper_kit.providers.cian.valuation import CianValuationResult, _save_to_cache # ── Part 1: bti_data → houses (detail.py) ──────────────────────────────────── @@ -53,8 +57,9 @@ def _mock_db_bti( address: str | None = "Екатеринбург, улица Малышева, 51", lat: float = 56.83, lon: float = 60.6, + city: str | None = "Екатеринбург", ) -> MagicMock: - """Mock db: SELECT address/lat/lon FROM listings → mappings().first() dict.""" + """Mock db: SELECT address/city/lat/lon FROM listings → mappings().first() dict.""" db = MagicMock() @contextmanager @@ -68,9 +73,10 @@ def _mock_db_bti( mock_result = MagicMock() mock_result.fetchone.return_value = None mock_result.scalar_one_or_none.return_value = None - if "SELECT address, lat, lon FROM listings" in sql_str: + if "SELECT address, city, lat, lon FROM listings" in sql_str: mock_result.mappings.return_value.first.return_value = { "address": address, + "city": city, "lat": lat, "lon": lon, } @@ -106,6 +112,9 @@ def test_bti_data_present_updates_house_columns_via_coalesce(): assert kwargs["ext_source"] == "cian_bti" assert kwargs["ext_id"] == "123" assert kwargs["address"] == "Екатеринбург, улица Малышева, 51" + # #2777: город-цель развёртки (listings.city) — независимое от адреса наблюдение; + # без него бескоординатная BTI-карточка матчится в одноимённый дом другого города. + assert kwargs["city"] == "Екатеринбург" call = _bti_update_call(db) assert call is not None, "UPDATE houses с BTI-полями не найден" @@ -190,6 +199,58 @@ def test_bti_missing_listing_address_skips_resolve(): db.commit.assert_called_once() +# ── Part 1b: bti_data реально извлекается с живой страницы (регрессия) ─────── +# +# Тесты выше кормят `bti_data` напрямую в `save_detail_enrichment`, поэтому они +# оставались зелёными, пока `fetch_detail` на живых страницах НИКОГДА его не +# заполнял: bti читался только как соседний с `defaultState` ключ контейнера +# frontend-offer-card, а Cian отдаёт его ВНУТРИ defaultState — `offerData.bti`. +# Итог на проде: 628 detail-обогащённых Cian-листингов, 0 домов с BTI-полями. +# Здесь прогоняем настоящий сохранённый HTML через настоящий `fetch_detail`. + + +def _detail_fixture_html() -> str: + path = os.path.join(os.path.dirname(__file__), "fixtures", "cian_flat_330982715.html") + with open(path, encoding="utf-8") as fh: + return fh.read() + + +async def test_fetch_detail_extracts_bti_from_offer_data_on_real_page(): + """Реальная страница Cian: bti лежит в offerData → bti_data должен заполниться.""" + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=_detail_fixture_html()) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/330982715/", browser_fetcher=fetcher) + + assert result is not None + assert result.bti_data, "bti_data пуст — BTI-персист (#2435) в проде остаётся no-op" + # Поля, которые пишет _persist_cian_bti_house (см. Part 1). + assert result.bti_data["entrances"] == 3 + assert result.bti_data["flatCount"] == 24 + assert result.bti_data["isEmergency"] is False + assert result.bti_data["houseHeatSupplyType"] == "central" + assert result.bti_data["houseGasSupplyType"] == "central" + assert result.bti_data["houseOverlapType"] == "concrete" + + +async def test_fetch_detail_bti_legacy_sibling_location_still_read(): + """Фоллбэк: если Cian вернёт bti соседним ключом контейнера — читаем и оттуда.""" + html = ( + "" + ) + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=html) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/777/", browser_fetcher=fetcher) + + assert result is not None + assert result.bti_data == {"seriesName": "1-464", "flatCount": 58} + + # ── Part 2: valuation house_info/managementCompany → houses (valuation.py) ── _HOUSE_INFO_SAMPLE = [ diff --git a/tradein-mvp/backend/tests/test_cian_session.py b/tradein-mvp/backend/tests/test_cian_session.py index ee4e50e6..4468e8ed 100644 --- a/tradein-mvp/backend/tests/test_cian_session.py +++ b/tradein-mvp/backend/tests/test_cian_session.py @@ -32,6 +32,21 @@ def mock_db() -> MagicMock: return db +@pytest.fixture(autouse=True) +def _mock_resolve_proxy_url_sync(monkeypatch: pytest.MonkeyPatch) -> None: + """#2825: verify_session больше не читает settings.cian_proxy_url напрямую, а + зовёт resolve_proxy_url_sync("cian") (пул scrape_proxies + fallback внутри + app.services.proxy_egress, отдельно покрыт tests/services/test_proxy_egress.py). + Без мока это реальный SessionLocal() -> живая (падающая в test-окружении) БД, + из-за чего verify_session уходил в generic except ДО session.get и все + verify_session-тесты ниже ловили не то, что проверяют. Мокаем на уровне модуля, + чтобы не трогать каждый тест по отдельности.""" + monkeypatch.setattr( + "app.services.cian_session.resolve_proxy_url_sync", + lambda source: "http://test-proxy.local:8080", + ) + + # --------------------------------------------------------------------------- # CIAN_REQUIRED_COOKIES # --------------------------------------------------------------------------- @@ -424,6 +439,40 @@ async def test_verify_session_network_error_returns_source_unavailable_sentinel( assert result is not None +@pytest.mark.asyncio +async def test_verify_session_pool_exhausted_returns_source_unavailable_with_error_log( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """#2825 fail-closed (#2616): пул scrape_proxies исчерпан для cian (все узлы + забанены/нездоровы) — session.get НЕ вызывается (никуда не ходим без egress), + возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, но с ERROR-логом (не warning, + отдельным от обычного network-error пути) — явная деградация, а не проглатывание.""" + from app.services.proxy_egress import ProxyPoolExhaustedError + + def _raise(source: str) -> str | None: + raise ProxyPoolExhaustedError( + source, pool_total=2, banned_for_source=2, unhealthy_or_disabled=0 + ) + + monkeypatch.setattr("app.services.cian_session.resolve_proxy_url_sync", _raise) + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.get = AsyncMock(return_value=_make_cffi_resp(200)) + + with ( + patch("app.services.cian_session.AsyncSession", return_value=mock_session), + caplog.at_level("WARNING"), + ): + result = await verify_session({"DMIR_AUTH": "x"}) + + assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL + mock_session.get.assert_not_called() + errors = [rec for rec in caplog.records if rec.levelname == "ERROR"] + assert any("пул прокси исчерпан" in rec.message.lower() for rec in errors) + + @pytest.mark.asyncio async def test_verify_session_uses_chrome120_impersonate() -> None: """curl_cffi AsyncSession must be constructed with impersonate='chrome120'.""" diff --git a/tradein-mvp/backend/tests/test_cian_views_parse.py b/tradein-mvp/backend/tests/test_cian_views_parse.py new file mode 100644 index 00000000..44d0488d --- /dev/null +++ b/tradein-mvp/backend/tests/test_cian_views_parse.py @@ -0,0 +1,101 @@ +"""#2669: Cian отдаёт просмотры фразой — разбор обязан доставать из неё числа. + +Замер на проде до правки: `listings.views_total` пуст у ВСЕХ 21 799 cian-строк +(0 из 1 571 detail-обогащённых), при том что у avito 12 350/12 352, у domklik +6 296/6 296, у yandex 1 146/1 210 — потеря ровно одна и только у Cian. + +Корень: `_parse_views` звала `int()` по всей фразе +`"146 просмотров, 8 за сегодня"` → ValueError → None (except в самой функции). + +Тесты гоняют настоящий сохранённый ответ (fixtures/cian_flat_330982715.html) +через настоящий `fetch_detail` + перебирают формы фразы. +""" + +from __future__ import annotations + +import logging +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.providers.cian.detail import _parse_views, fetch_detail + +_FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "cian_flat_330982715.html") + +# Дословно из фикстуры: "stats":{"totalViewsFormattedString":"146 просмотров, 8 за сегодня"} +_FIXTURE_PHRASE = "146 просмотров, 8 за сегодня" + + +def _fixture_html() -> str: + with open(_FIXTURE, encoding="utf-8") as fh: + return fh.read() + + +def test_fixture_still_carries_the_phrase() -> None: + """Страховка: тесты ниже бессмысленны, если фикстура перестала содержать фразу.""" + assert f'"totalViewsFormattedString":"{_FIXTURE_PHRASE}"' in _fixture_html() + + +@pytest.mark.parametrize( + ("phrase", "expected"), + [ + (_FIXTURE_PHRASE, (146, 8)), # форма из сохранённого ответа + ("1 просмотр", (1, None)), # единственное число, хвоста нет + ("2 просмотра", (2, None)), + ("0 просмотров", (0, None)), # ноль — это 0, а не «нет данных» + ("1 234 просмотра", (1234, None)), # разделитель тысяч — обычный пробел + ("1\xa0234 просмотра", (1234, None)), # ... и NBSP + ("12 345 просмотров, 1\xa0234 за сегодня", (12345, 1234)), # narrow NBSP + ("1 234", (1234, None)), # голое число (форма из старого докстринга) + ("", (None, None)), + (None, (None, None)), + ("просмотров нет", (None, None)), # без цифр — пусто, а не 0 + ("нет данных", (None, None)), # ответ площадки «нечего показать» + ], +) +def test_parse_views_forms(phrase: str | None, expected: tuple[int | None, int | None]) -> None: + assert _parse_views(phrase) == expected + + +def test_unparsed_phrase_is_logged_with_itself(caplog: pytest.LogCaptureFixture) -> None: + """Провал разбора обязан быть отличим от «Циан не прислал» (эпик #2674). + + Прошлая пропажа держалась месяцами ровно потому, что в журнале не было ни + строки: пустая колонка одинаково выглядела и при сломанном разборе, и при + молчании площадки. Поэтому в warning уходит САМА фраза. + """ + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"): + assert _parse_views("нет данных") == (None, None) + + assert "нет данных" in caplog.text, "неразобранная фраза не видна в журнале" + + +def test_absent_phrase_is_not_logged(caplog: pytest.LogCaptureFixture) -> None: + """Циан не прислал ключ вовсе — это не поломка разбора, шуметь не о чем.""" + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"): + assert _parse_views(None) == (None, None) + + assert caplog.text == "" + + +def test_no_warning_when_today_tail_absent(caplog: pytest.LogCaptureFixture) -> None: + """«0 просмотров» без хвоста «за сегодня» — штатная форма, не повод для warning.""" + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"): + assert _parse_views("0 просмотров") == (0, None) + + assert caplog.text == "" + + +async def test_fetch_detail_fills_views_from_real_saved_page() -> None: + """Настоящая сохранённая страница → views_total/views_today непусты (#2669).""" + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=_fixture_html()) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/330982715/", browser_fetcher=fetcher) + + assert result is not None + assert result.views_total == 146, "views_total снова теряется — колонка останется пустой" + assert result.views_today == 8 diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 2e763b20..3f132e1f 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -150,7 +150,9 @@ def test_scrape_runs_mark_cancelled_returns_bool() -> None: from app.services.scrape_runs import mark_cancelled mock_db = MagicMock() - mock_db.execute.return_value.fetchone.return_value = MagicMock() # row found + # source обязателен: #2674 добавил гейт honors_cancel — отменять можно только то, + # что отмену опрашивает (иначе 'cancelled' у живой задачи + обход has_running_run). + mock_db.execute.return_value.fetchone.return_value = MagicMock(source="avito_city_sweep") result = mark_cancelled(mock_db, 10) assert result is True diff --git a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py new file mode 100644 index 00000000..b87af5ae --- /dev/null +++ b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py @@ -0,0 +1,89 @@ +"""ЭТАП 4 B2C launch — consent-text sync guard (part D). + +WHY: + _CONSENT_TEXT_SNAPSHOT in app/api/v1/lead.py is a durable 152-ФЗ + proof-of-consent: it must be the EXACT text a user actually saw and agreed + to. Before this test, the only thing keeping it in sync with the real + frontend checkbox label (LeadForm.tsx) was a code COMMENT ("Должен + ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx"). A comment cannot fail CI + -- a frontend copy edit could silently drift from the backend snapshot, + and every future lead's "proof" would then misrepresent what the user + actually saw. + +WHAT: + Extract the actual consent-checkbox label text straight out of + LeadForm.tsx (regex, no JSX parser needed -- there is exactly one + 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. + + NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT + in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet + -- the anonymous /estimate flow isn't live (rbac_guard still requires + X-Authenticated-User on every non-public path, see app/core/rbac.py). When + that flow ships its own consent checkbox, add a second sync test here + mirroring this one -- do NOT rely on a comment for that pairing either. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_FRONTEND_LEAD_FORM = ( + Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "components" + / "trade-in" + / "v2" + / "LeadForm.tsx" +) + + +def _extract_span_text(tsx_source: str) -> str: + """Pull the text content of the (single) ... in LeadForm.tsx, + whitespace-normalised the same way a browser collapses JSX text-node + whitespace when rendering (multiple lines/indentation -> single spaces). + """ + match = re.search(r"\s*(.*?)\s*", tsx_source, re.DOTALL) + assert match is not None, "no found in LeadForm.tsx -- consent label markup changed" + return re.sub(r"\s+", " ", match.group(1)).strip() + + +def test_frontend_lead_form_exists() -> None: + assert _FRONTEND_LEAD_FORM.is_file(), f"missing frontend file: {_FRONTEND_LEAD_FORM}" + + +def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None: + """The whole point: this FAILS if lead.py._CONSENT_TEXT_SNAPSHOT and + LeadForm.tsx's checkbox label ever diverge -- no longer just a comment.""" + from app.api.v1.lead import _CONSENT_TEXT_SNAPSHOT + + frontend_text = _extract_span_text(_FRONTEND_LEAD_FORM.read_text(encoding="utf-8")) + backend_text = re.sub(r"\s+", " ", _CONSENT_TEXT_SNAPSHOT).strip() + + assert frontend_text == backend_text, ( + "consent text drift detected between app/api/v1/lead.py._CONSENT_TEXT_SNAPSHOT " + "and frontend/src/components/trade-in/v2/LeadForm.tsx checkbox label -- the " + "152-ФЗ proof-of-consent snapshot no longer matches what users actually see. " + "Bump _CONSENT_POLICY_VERSION and update _CONSENT_TEXT_SNAPSHOT together with " + "any frontend copy change.\n" + f" frontend: {frontend_text!r}\n" + f" backend: {backend_text!r}" + ) + + +def test_extract_span_text_helper_is_whitespace_insensitive() -> None: + """Sanity check on the extraction helper itself, independent of the real file.""" + sample = """ + + Line one + Line two + + """ + assert _extract_span_text(sample) == "Line one Line two" diff --git a/tradein-mvp/backend/tests/test_data_erasure.py b/tradein-mvp/backend/tests/test_data_erasure.py new file mode 100644 index 00000000..9d0e039c --- /dev/null +++ b/tradein-mvp/backend/tests/test_data_erasure.py @@ -0,0 +1,378 @@ +"""ЭТАП 4 B2C launch — right-to-erasure mechanism (part C). + +Covers app/services/data_erasure.py: + - at least one identifier required (ValueError, no db.execute at all) + - username (B2B pilot): estimates + their leads + web_support_threads deleted + - estimate_ids only (anonymous, has the link/PDF): estimates + linked leads deleted, + web_support/tg_support untouched (no username = nothing to key them by) + - phone only: only leads deleted (no estimate/support action) + - tg_chat_id only: only tg_support deleted (anonymous Telegram-support path) + - ORDER: leads are captured/deleted BEFORE estimates (estimate_id FK is + ON DELETE SET NULL -- deleting estimates first would orphan the join) + - commits once at the end +""" + +from __future__ import annotations + +import os +import re +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import data_erasure + + +class _Result: + def __init__(self, rowcount: int = 0, scalar_ids: list[Any] | None = None) -> None: + self.rowcount = rowcount + self._scalar_ids = scalar_ids or [] + + def scalars(self) -> SimpleNamespace: + return SimpleNamespace(all=lambda: self._scalar_ids) + + +def _sql_of(call: Any) -> str: + stmt = call.args[0] + return str(getattr(stmt, "text", stmt)) + + +def test_requires_at_least_one_identifier() -> None: + db = MagicMock() + with pytest.raises(ValueError, match="at least one identifier"): + data_erasure.erase_person_data(db) + assert not db.execute.called + assert not db.commit.called + + +def test_erase_by_username_deletes_estimates_leads_and_web_support() -> None: + db = MagicMock() + owned_id = uuid4() + db.execute.side_effect = [ + _Result(scalar_ids=[owned_id]), # SELECT id FROM trade_in_estimates WHERE created_by + _Result(rowcount=2), # DELETE FROM trade_in_leads + _Result(rowcount=1), # DELETE FROM trade_in_estimates + _Result(rowcount=3), # DELETE FROM web_support_threads + ] + + out = data_erasure.erase_person_data(db, username="kopylov") + + assert out == { + "trade_in_estimates_deleted": 1, + "trade_in_leads_deleted": 2, + "web_support_deleted": 3, + "tg_support_deleted": 0, + } + assert db.commit.called + + calls = db.execute.call_args_list + assert "SELECT id FROM trade_in_estimates" in _sql_of(calls[0]) + assert "created_by" in _sql_of(calls[0]) + assert "DELETE FROM trade_in_leads" in _sql_of(calls[1]) + assert "DELETE FROM trade_in_estimates" in _sql_of(calls[2]) + assert "DELETE FROM web_support_threads" in _sql_of(calls[3]) + # estimate_ids captured from the SELECT reach the estimates DELETE. + estimates_delete_params = calls[2].args[1] + assert str(owned_id) in estimates_delete_params["ids"] + + +def test_leads_deleted_before_estimates_order() -> None: + """FK trade_in_leads.estimate_id is ON DELETE SET NULL -- capturing/deleting + leads must happen BEFORE the estimates DELETE, else the join key is gone.""" + db = MagicMock() + owned_id = uuid4() + db.execute.side_effect = [ + _Result(scalar_ids=[owned_id]), + _Result(rowcount=0), + _Result(rowcount=1), + _Result(rowcount=0), + ] + data_erasure.erase_person_data(db, username="kopylov") + calls = db.execute.call_args_list + leads_idx = next(i for i, c in enumerate(calls) if "DELETE FROM trade_in_leads" in _sql_of(c)) + estimates_idx = next( + i for i, c in enumerate(calls) if "DELETE FROM trade_in_estimates" in _sql_of(c) + ) + assert leads_idx < estimates_idx + + +def test_erase_by_estimate_ids_only_no_web_or_tg_support_touched() -> None: + db = MagicMock() + eid = uuid4() + db.execute.side_effect = [ + _Result(rowcount=1), # DELETE FROM trade_in_leads (matches estimate_id) + _Result(rowcount=1), # DELETE FROM trade_in_estimates + ] + + out = data_erasure.erase_person_data(db, estimate_ids=[eid]) + + assert out == { + "trade_in_estimates_deleted": 1, + "trade_in_leads_deleted": 1, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + assert db.execute.call_count == 2 # no username -> no SELECT, no web_support DELETE + + +def test_erase_by_phone_only_touches_only_leads() -> None: + db = MagicMock() + db.execute.side_effect = [_Result(rowcount=1)] # DELETE FROM trade_in_leads WHERE phone=... + + out = data_erasure.erase_person_data(db, phone="+79123456789") + + assert out == { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 1, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + assert db.execute.call_count == 1 + params = db.execute.call_args_list[0].args[1] + assert params["phone"] == "+79123456789" + assert params["ids"] == [] + + +def test_phone_delete_normalizes_digits_on_both_sides() -> None: + """Regression guard for the deep-review MEDIUM finding (2026-08-06) + + follow-up (RU 8-vs-7 trunk prefix): lead.py stores phone exactly as typed + (no E.164 normalization, by design), so a differently-formatted-but- + same-number erasure request must still match, AND the RU '8...' vs + '+7...' trunk-prefix pair must collapse to the same canonical value. The + old exact `phone = :phone` comparison silently deleted 0 rows and still + returned HTTP 200 'erased' -- worse than an honest error under 152-ФЗ. + Both sides must go through the SAME normalization (_PHONE_COLUMN_NORM_SQL + / _PHONE_PARAM_NORM_SQL, see _ru_phone_norm_sql), and the literal-equality + path must be gone.""" + db = MagicMock() + db.execute.side_effect = [_Result(rowcount=1)] + + data_erasure.erase_person_data(db, phone="+7 999 123-45-67") + + sql = _sql_of(db.execute.call_args_list[0]) + # The comparison uses EXACTLY the two module-level normalized fragments + # (not a hand-rolled inline duplicate) -- pins that both sides go through + # the SAME normalization function, not two independently-drifting copies. + col_norm = data_erasure._PHONE_COLUMN_NORM_SQL + param_norm = data_erasure._PHONE_PARAM_NORM_SQL + assert f"{col_norm} = {param_norm}" in sql + # RU trunk-prefix collapse present on BOTH sides (11 digits, leading '8' -> '7'). + assert sql.count("length(regexp_replace") == 2 + assert sql.count("= '8'") == 2 + assert sql.count("'7' ||") == 2 + assert "phone = :phone" not in sql # old literal-equality path must be GONE + assert not re.search(r":\w+::", sql) # psycopg v3 CAST trap + + +def test_ru_phone_norm_sql_only_ever_takes_static_expressions() -> None: + """`_ru_phone_norm_sql` is a query-STRUCTURE builder, not a data path -- + the two module-level constants are the ONLY call sites, and both pass a + column name / CAST(:bind AS type), never an actual phone value. This + pins that contract so a future call site can't accidentally splice a + real phone string into the SQL text.""" + assert data_erasure._PHONE_COLUMN_NORM_SQL == data_erasure._ru_phone_norm_sql("phone") + assert data_erasure._PHONE_PARAM_NORM_SQL == data_erasure._ru_phone_norm_sql( + "CAST(:phone AS text)" + ) + # column side references the column, never the bind param; param side is the reverse. + assert ":phone" not in data_erasure._PHONE_COLUMN_NORM_SQL + assert "CAST(:phone AS text)" in data_erasure._PHONE_PARAM_NORM_SQL + + +def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None: + """Anonymous person with NO username, NO estimate link, NO lead phone -- but + they DID message @MERAsupport_bot -- can still be identified by their own + Telegram chat_id (see module docstring: not spoofable by a third party).""" + db = MagicMock() + db.execute.side_effect = [ + _Result(rowcount=0), # DELETE FROM trade_in_leads (no ids, no phone -> matches nothing) + _Result(rowcount=5), # DELETE FROM tg_support_users + ] + + out = data_erasure.erase_person_data(db, tg_chat_id=123456789) + + assert out == { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 0, + "web_support_deleted": 0, + "tg_support_deleted": 5, + } + calls = db.execute.call_args_list + assert "DELETE FROM tg_support_users" in _sql_of(calls[-1]) + assert calls[-1].args[1]["chat_id"] == 123456789 + + +# ── Optional real-Postgres behavioural test (self-skips without a DB) ────────── +# Same pattern as tests/test_house_dedup_merge.py::_live_session -- CI runs the +# mock-only lane (DATABASE_URL is a placeholder), so this self-skips there; it +# only executes with a real reachable Postgres (e.g. local dev DB). + + +def _live_session() -> Any | None: + """Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None.""" + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + from sqlalchemy import text as _t + + conn.execute(_t("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_finds_differently_formatted_number() -> None: + """End-to-end on a real DB: a lead stored with phone EXACTLY as typed + ('89991234567', no separators) must still be found and deleted when the + erasure requester supplies the SAME digits in a DIFFERENT format + ('8 (999) 123-45-67') -- proves the regexp_replace normalization fix + actually matches, not just that the SQL text contains the function name.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + out = data_erasure.erase_person_data(db, phone="8 (999) 123-45-67") + + assert out["trade_in_leads_deleted"] == 1 + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 0 + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_finds_ru_trunk_prefix_variant() -> None: + """End-to-end on a real DB: the coordinator's exact follow-up gap + (2026-08-06) -- a lead stored as '89991234567' (leading '8') must be + found and deleted when the erasure requester supplies '+7 999 123-45-67' + (leading '+7'). Pure digit-stripping does NOT close this: stripped, the + two are '89991234567' vs '79991234567' -- different at digit 1. Only the + explicit 11-digit '8'->'7' collapse in _ru_phone_norm_sql makes them + equal. Counterfactual proven manually against this same DB (raw SQL, + see PR discussion): WITHOUT the collapse, `regexp_replace` alone finds 0 + rows for this exact pair.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + # Counterfactual: plain digit-stripping (the PRE-follow-up fix) does NOT + # match this pair -- proves the 8-vs-7 gap was real, not a strawman. + digits_only_match = db.execute( + _t( + "SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid) " + "AND regexp_replace(phone, '\\D', '', 'g') " + "= regexp_replace(CAST(:phone AS text), '\\D', '', 'g')" + ), + {"id": str(lead_id), "phone": "+7 999 123-45-67"}, + ).scalar() + assert digits_only_match == 0, "digit-stripping alone must NOT match 8- vs 7-prefix" + + out = data_erasure.erase_person_data(db, phone="+7 999 123-45-67") + + assert out["trade_in_leads_deleted"] == 1 + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 0 + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_does_not_match_different_number() -> None: + """Negative control: a number differing in even ONE significant digit + must NOT be found -- proves the normalization is an exact-identity + check, not a fuzzy/truncated match that could delete a STRANGER's data. + Stored '89991234567' vs requested '+7 999 123-45-68' (last digit 7->8) + -- same length, same RU-looking shape, one digit off -- zero rows.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + out = data_erasure.erase_person_data(db, phone="+7 999 123-45-68") + + assert out["trade_in_leads_deleted"] == 0, "one differing digit must NOT match" + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 1, "row must survive an erasure request for a DIFFERENT number" + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_health_gate.py b/tradein-mvp/backend/tests/test_deactivate_stale_health_gate.py new file mode 100644 index 00000000..89d4ef30 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_health_gate.py @@ -0,0 +1,321 @@ +"""Гейт по здоровью сбора для TTL-деактивации (#2659). + +Ключевой тест здесь — test_gate_blocks_every_day_of_the_17_day_avito_ban: он +проигрывает РЕАЛЬНЫЙ прод-ряд подтверждений по дням и требует, чтобы порог из +миграции 219 заблокировал каждые сутки провала 10.07-26.07.2026 и не тронул ни +одних здоровых суток. На старом коде (без min_confirmations) он не проходит: +деактивация исполнялась вслепую. +""" + +from __future__ import annotations + +import os +import re +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 + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_219 = _SQL_DIR / "219_deactivate_stale_health_gate.sql" + +# ── Прод-ряд: подтверждений за 3 суток по дням, avito, все сегменты ──────────── +# Восстановлено из listing_source_snapshots (снимок last_seen_at на каждую дату): +# count(*) FILTER (WHERE last_seen_at > snapshot_date - interval '3 days') +# Провал сбора: 08.07-29.07.2026 (17 суток, за которые TTL=10 снял 9 033 строки). +# Здоровые сутки — до 07.07 и после восстановления 03.08. +_AVITO_BAN_DAYS: dict[str, int] = { + "2026-07-08": 936, + "2026-07-09": 970, + "2026-07-10": 830, + "2026-07-11": 300, + "2026-07-13": 160, + "2026-07-14": 683, + "2026-07-15": 683, + "2026-07-16": 665, + "2026-07-17": 0, + "2026-07-18": 0, + "2026-07-19": 0, + "2026-07-20": 0, + "2026-07-21": 0, + "2026-07-22": 0, + "2026-07-23": 0, + "2026-07-24": 0, + "2026-07-25": 0, + "2026-07-27": 0, + "2026-07-28": 0, + "2026-07-29": 0, +} +_AVITO_HEALTHY_DAYS: dict[str, int] = { + "2026-06-25": 3542, + "2026-06-26": 3902, + "2026-06-27": 4017, + "2026-06-28": 3894, + "2026-06-29": 4424, + "2026-06-30": 4943, + "2026-07-01": 5270, + "2026-07-02": 4828, + "2026-07-05": 6079, + "2026-07-06": 5060, + "2026-07-07": 3666, + "2026-08-03": 4039, + "2026-08-04": 4247, + "2026-08-05": 4197, + "2026-08-06": 2542, +} +# Порог из миграции 219 для deactivate_stale_avito. +_AVITO_MIN_CONFIRMATIONS = 1500 + +# Сколько строк TTL снял в каждые сутки провала (scrape_runs.counters->>'deactivated'). +# Сумма = 9 033 — цифра из #2659, перепроверена на проде. +_AVITO_BAN_DEACTIVATED = [316, 970, 742, 676, 1541, 2959, 216, 536, 146, 95, 153, 0, 0, 683] + + +# ── Фейковая сессия ─────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rowcount: int = 0, scalar_value: int | None = None) -> None: + self.rowcount = rowcount + self._scalar = scalar_value + + def scalar(self) -> int | None: + return self._scalar + + +class _FakeDB: + """Session-заглушка: SELECT count(*) отдаёт confirmations, UPDATE — rowcount.""" + + def __init__(self, *, confirmations: int, rowcount: int = 137) -> None: + 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 "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_statements(self) -> list[str]: + return [sql for sql, _ in self.executed if "UPDATE listings" in sql] + + +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, + ) + + +# ── Исторический случай: 17 суток бана Авито ────────────────────────────────── + + +def test_gate_blocks_every_day_of_the_17_day_avito_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одни сутки провала 08.07-29.07 не должны пропустить деактивацию.""" + for day, confirmations in _AVITO_BAN_DAYS.items(): + db = _FakeDB(confirmations=confirmations) + out = _run(db, monkeypatch, min_confirmations=_AVITO_MIN_CONFIRMATIONS) + assert out["skipped_unhealthy"] == 1, f"{day}: гейт пропустил провальные сутки" + assert out["deactivated"] == 0, f"{day}: деактивировано ненулевое количество" + assert db.update_statements == [], f"{day}: UPDATE listings всё-таки исполнился" + + +def test_gate_passes_every_healthy_avito_day(monkeypatch: pytest.MonkeyPatch) -> None: + """Здоровые сутки порог 1500 не блокирует — гейт не ломает штатную работу.""" + for day, confirmations in _AVITO_HEALTHY_DAYS.items(): + db = _FakeDB(confirmations=confirmations) + out = _run(db, monkeypatch, min_confirmations=_AVITO_MIN_CONFIRMATIONS) + assert "skipped_unhealthy" not in out, f"{day}: гейт заблокировал здоровые сутки" + assert out["deactivated"] == 137, f"{day}: деактивация не исполнилась" + assert len(db.update_statements) == 1, f"{day}: UPDATE listings не исполнился" + + +def test_threshold_separates_ban_from_health() -> None: + """Порог лежит строго между максимумом провала и минимумом здоровых суток.""" + assert max(_AVITO_BAN_DAYS.values()) < _AVITO_MIN_CONFIRMATIONS + assert min(_AVITO_HEALTHY_DAYS.values()) > _AVITO_MIN_CONFIRMATIONS + + +def test_ban_window_damage_matches_issue_number() -> None: + """Ущерб исторического случая — 9 033 строки (#2659), гейт спасает их все.""" + assert sum(_AVITO_BAN_DEACTIVATED) == 9033 + + +# ── Контракт гейта ──────────────────────────────────────────────────────────── + + +def test_gate_disabled_by_default_keeps_old_behaviour(monkeypatch: pytest.MonkeyPatch) -> None: + """min_confirmations=0 -> ни одного лишнего запроса, поведение как до #2659.""" + db = _FakeDB(confirmations=0) + out = _run(db, monkeypatch) + assert out == {"deactivated": 137} + assert len(db.executed) == 1 + + +def test_gate_reports_confirmations_when_passing(monkeypatch: pytest.MonkeyPatch) -> None: + """Прошедший гейт прогон всё равно пишет замер — счётчик виден оператору.""" + db = _FakeDB(confirmations=4000) + out = _run(db, monkeypatch, min_confirmations=1500) + assert out["confirmations"] == 4000 + assert out["deactivated"] == 137 + + +def test_gate_runs_before_any_write(monkeypatch: pytest.MonkeyPatch) -> None: + """SELECT-проверка идёт ПЕРВОЙ: деактивация необратима, откат после неё не спасает.""" + db = _FakeDB(confirmations=4000) + _run(db, monkeypatch, min_confirmations=1500) + assert "SELECT count(*)" in db.executed[0][0] + assert "UPDATE listings" in db.executed[1][0] + + +def test_blocked_run_does_not_commit(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(confirmations=10) + _run(db, monkeypatch, min_confirmations=1500) + assert db.committed is False + assert db.rolled_back is True + + +def test_blocked_run_is_finalised_as_done(monkeypatch: pytest.MonkeyPatch) -> None: + """Пропущенный прогон закрывается mark_done, а не висит 'running' до zombie-жатвы.""" + 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(confirmations=10) + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 77, + listing_source="avito", + ttl_days=10, + min_confirmations=1500, + ) + assert marked["run_id"] == 77 + assert marked["counters"]["skipped_unhealthy"] == 1 + assert marked["counters"]["deactivated"] == 0 + + +def test_gate_measures_same_slice_as_update(monkeypatch: pytest.MonkeyPatch) -> None: + """Срез гейта совпадает со срезом UPDATE: тот же source и те же сегменты.""" + db = _FakeDB(confirmations=4000) + _run(db, monkeypatch, segments=["vtorichka"], min_confirmations=500) + health_sql, health_params = db.executed[0] + assert "ANY(CAST(:segments AS text[]))" in health_sql + assert health_params is not None + assert health_params["segments"] == ["vtorichka"] + assert health_params["listing_source"] == "avito" + + +def test_gate_uses_same_staleness_column_as_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """domklik считает свежесть по scraped_at (#2204) — гейт обязан мерить ту же колонку, + иначе bulk-touch по last_seen_at показал бы здоровье там, где сбора нет.""" + db = _FakeDB(confirmations=4000) + _run(db, monkeypatch, staleness_column="scraped_at", min_confirmations=200) + health_sql = db.executed[0][0] + assert "scraped_at" in health_sql + assert "last_seen_at" not in health_sql + + +def test_gate_rejects_invalid_staleness_column(monkeypatch: pytest.MonkeyPatch) -> None: + """Whitelist колонки работает и на пути гейта — интерполяции чужого имени нет.""" + db = _FakeDB(confirmations=4000) + with pytest.raises(ValueError): + _run(db, monkeypatch, staleness_column="is_active", min_confirmations=500) + assert db.executed == [] + + +def test_confirmations_sql_is_psycopg_v3_safe() -> None: + sql = str(task_mod._build_confirmations_sql("last_seen_at", with_segments=True).text) + assert "CAST(:health_window_days || ' days' AS interval)" in sql + assert not re.search(r":\w+::", sql) + assert "UPDATE" not in sql.upper() + assert "DELETE" not in sql.upper() + + +def test_default_min_confirmations_is_a_safety_net_not_zero() -> None: + """Незасеянное расписание получает страховку, а не «деактивируй вслепую».""" + assert task_mod.DEFAULT_MIN_CONFIRMATIONS > 0 + + +def test_handler_wires_min_confirmations_from_schedule_params() -> None: + """Читаем исходник файлом: 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] + assert 'params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)' in job + assert "min_confirmations=min_confirmations" in job + + +# ── Миграция 219 ────────────────────────────────────────────────────────────── + + +def test_migration_219_exists() -> None: + assert _MIGRATION_219.is_file(), f"missing migration: {_MIGRATION_219}" + + +def test_migration_219_seeds_all_four_schedules() -> None: + sql = _MIGRATION_219.read_text("utf-8") + for source in ( + "deactivate_stale_avito", + "deactivate_stale_cian", + "deactivate_stale_yandex", + "deactivate_stale_domklik", + ): + assert f"'{source}'" in sql, f"{source} без порога — деактивирует вслепую" + + +def test_migration_219_avito_threshold_catches_the_ban() -> None: + """Порог авито должен быть выше максимума провальных суток (970).""" + sql = _MIGRATION_219.read_text("utf-8") + avito_block = sql.split("WHERE source = 'deactivate_stale_avito'")[0] + match = re.findall(r"'min_confirmations',\s*(\d+)", avito_block) + assert match, "порог авито не найден в миграции" + assert int(match[-1]) == _AVITO_MIN_CONFIRMATIONS + assert int(match[-1]) > max(_AVITO_BAN_DAYS.values()) + + +def test_migration_219_is_transactional_and_idempotent() -> None: + sql = _MIGRATION_219.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + # Повторный прогон не затирает подкрученное оператором значение. + assert sql.count("NOT default_params ? 'min_confirmations'") == 3 + + +def test_migration_219_touches_only_deactivate_schedules() -> None: + sql = _MIGRATION_219.read_text("utf-8") + for line in sql.splitlines(): + if line.strip().startswith("WHERE source"): + assert "deactivate_stale_" in line + + +def test_migration_219_no_psycopg_trap() -> None: + sql = _MIGRATION_219.read_text("utf-8") + assert not re.search(r":\w+::", sql) diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py new file mode 100644 index 00000000..76f34665 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -0,0 +1,334 @@ +"""Пол TTL по измеренному циклу переобхода (#2659). + +Гейт здоровья (#2710) отвечает «источник собирается?». Этот пол отвечает на второй +вопрос issue — «а достаточно ли ttl_days, чтобы молчание означало снятие?». Пока свип +возвращается к строке реже, чем раз в ttl_days, TTL меряет нашу выборку, а не жизнь +объявления, и источник при этом ЗДОРОВ — гейт молчит. + +Ключевой тест — test_effective_ttl_covers_every_proven_false_kill: он проигрывает +РЕАЛЬНЫЙ прод-случай (127 строк, снятых на возрасте 29.9..30.3 суток при TTL=30 и +доказанно вернувшихся живыми) и требует, чтобы эффективный TTL накрыл каждую. На +старом коде — без пола — эффективный TTL остаётся 30, и тест падает на всех срезах. +""" + +from __future__ import annotations + +import os +import re +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 + +# ── Прод-замер 2026-08-09 (read-only), из-за которого пол существует ─────────── +# С деплоя гейта 06.08 TTL снял 1 028 строк, 127 уже снова активны (12.4%). +# В Екатеринбурге — единственном городе с настоящим покрытием — доля ложных 100%. +_FALSE_KILLS_BY_CITY: dict[str, tuple[int, int]] = { # срез -> (снято, снова активны) + "cian/Екатеринбург": (103, 103), + "yandex/Екатеринбург": (24, 24), + "cian/без города": (560, 0), + "yandex/без города": (341, 0), +} +# Возраст строки на момент снятия у всех 127 доказанно ложных снятий, суток. +_FALSE_KILL_AGE_MIN = 29.9 +_FALSE_KILL_AGE_MAX = 30.3 + +# Пол, который отдаёт ТОТ ЖЕ запрос на проде (percentile_disc 0.99, окно 3 суток, +# срез = срез TTL-джобы). Ключ -> (listing_source, segments, ttl_days сейчас, пол). +_PROD_FLOORS: dict[str, tuple[str, list[str] | None, int, float]] = { + "cian/vtorichka": ("cian", ["vtorichka"], 30, 34.0), + "yandex/vtorichka": ("yandex", ["vtorichka"], 30, 74.3), + "avito/все сегменты": ("avito", None, 10, 69.7), +} + +# Разрывы переобхода по срезу TTL-джобы (listing_source_snapshots, 40 суток): +# источник -> (p90, p99, ttl_days сейчас). Домклик — контрольная группа: почти +# полное суточное покрытие, TTL лежит в 4.5 раза выше хвоста, и снятие у него +# действительно означает снятие. +_REVISIT_TAIL: dict[str, tuple[float, float, int]] = { + "domklik/vtorichka": (1.9, 3.1, 14), + "cian/vtorichka": (10.9, 26.6, 30), + "yandex/vtorichka": (5.7, 43.0, 30), + "avito/vtorichka": (29.1, 42.1, 10), +} + + +# ── Фейковая сессия ─────────────────────────────────────────────────────────── + + +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 floor_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "percentile_disc" in e[0]), ("", None)) + + @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", "cian"), + ttl_days=kwargs.pop("ttl_days", 30), + **kwargs, + ) + + +# ── Исторический случай: 127 доказанных ложных снятий ───────────────────────── + + +def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одно из 127 доказанно ложных снятий не должно повториться. + + Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть + строго выше этого возраста на КАЖДОМ прод-срезе — иначе следующий прогон + снимет ту же строку снова. + """ + for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items(): + 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, + ) + effective = out["ttl_days_effective"] + assert effective > _FALSE_KILL_AGE_MAX, ( + f"{slice_name}: эффективный TTL {effective} не накрывает возраст ложного " + f"снятия {_FALSE_KILL_AGE_MAX} — 127 строк снимутся снова" + ) + _, update_params = db.update_query + assert update_params is not None + assert ( + update_params["ttl_days"] == effective + ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен" + + +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 + for source, _segments, ttl_days, _floor in _PROD_FLOORS.values(): + if source in ("cian", "yandex"): + assert ttl_days == 30, f"{source}: прод-TTL разошёлся с замером" + + +def test_false_kill_rate_is_total_where_coverage_is_real() -> None: + """В городе с настоящим покрытием ложны ВСЕ снятия — это и есть корень #2659.""" + for slice_name in ("cian/Екатеринбург", "yandex/Екатеринбург"): + killed, returned = _FALSE_KILLS_BY_CITY[slice_name] + assert returned == killed, f"{slice_name}: замер разошёлся" + total_killed = sum(k for k, _ in _FALSE_KILLS_BY_CITY.values()) + total_returned = sum(r for _, r in _FALSE_KILLS_BY_CITY.values()) + assert total_killed == 1028 + assert total_returned == 127 + + +def test_control_group_domklik_needs_no_floor() -> None: + """Домклик — контроль: при почти полном суточном обходе TTL с запасом выше хвоста. + + Если бы пол был нужен всем подряд, он был бы нужен и источнику со сплошным + покрытием. Ему не нужен — значит меряем именно покрытие, а не «TTL маловат». + + Разделяет источники ЗАПАС над хвостом, а не сам факт превышения: у домклика + TTL/p99 = 4.5, у остальных трёх 0.24..1.13, то есть порог сидит вплотную к + хвосту или внутри него. Именно у самого «благополучного» из трёх (cian, 1.13) + и случились 103 доказанно ложных снятия: p99 запаса не даёт, снимает 1% живых. + """ + _p90, p99, ttl = _REVISIT_TAIL["domklik/vtorichka"] + assert ttl / p99 > 4, "домклик перестал быть контрольной группой — перемерить" + for name, (_p90, p99, ttl) in _REVISIT_TAIL.items(): + if name.startswith("domklik"): + continue + assert ttl / p99 < 1.5, f"{name}: TTL отошёл от хвоста обхода, замер устарел" + + +# ── Контракт пола ───────────────────────────────────────────────────────────── + + +def test_floor_never_lowers_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол поднимает порог и только. Здоровый источник с коротким циклом не даёт + поводу снять больше, чем задано оператором.""" + db = _FakeDB(floor_days=3.1) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 30 + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 30 + + +def test_floor_is_rounded_up_not_down(monkeypatch: pytest.MonkeyPatch) -> None: + """34.0 суток разрыва -> TTL 34, а 34.2 -> 35: округление в сторону осторожности.""" + db = _FakeDB(floor_days=34.2) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert out["revisit_floor_days"] == 35 + assert out["ttl_days_effective"] == 35 + + +def test_floor_disabled_keeps_old_behaviour(monkeypatch: pytest.MonkeyPatch) -> None: + """quantile=0 -> ни одного лишнего запроса, поведение как до правки.""" + db = _FakeDB(floor_days=74.3) + out = _run(db, monkeypatch, ttl_days=30) + assert out == {"deactivated": 137} + assert len(db.executed) == 1 + assert "percentile_disc" not in db.executed[0][0] + + +def test_missing_snapshot_history_leaves_ttl_as_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """NULL (истории снимков за окно нет) -> пол не выдумывается, TTL как задан.""" + db = _FakeDB(floor_days=None) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert "revisit_floor_days" not in out + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 30 + + +def test_floor_runs_before_any_write(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол считается ДО UPDATE: снятое объявление возвращает только повторный сбор.""" + db = _FakeDB(floor_days=74.3) + _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + kinds = ["floor" if "percentile_disc" in sql else "update" for sql, _ in db.executed] + assert kinds.index("floor") < kinds.index("update") + + +def test_floor_measures_same_slice_as_update(monkeypatch: pytest.MonkeyPatch) -> None: + """Срез пола совпадает со срезом UPDATE: тот же source и те же сегменты.""" + db = _FakeDB(floor_days=74.3) + _run( + db, + monkeypatch, + listing_source="yandex", + segments=["vtorichka"], + revisit_floor_quantile=0.99, + ) + floor_sql, floor_params = db.floor_query + assert "ANY(CAST(:segments AS text[]))" in floor_sql + assert floor_params is not None + assert floor_params["segments"] == ["vtorichka"] + assert floor_params["listing_source"] == "yandex" + + +def test_floor_uses_same_staleness_column_as_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """domklik считает свежесть по scraped_at (#2204) — пол обязан мерить ту же колонку, + иначе bulk-touch по last_seen_at показал бы цикл обхода там, где сбора нет.""" + db = _FakeDB(floor_days=20.0) + _run( + db, + monkeypatch, + listing_source="domklik", + ttl_days=14, + staleness_column="scraped_at", + revisit_floor_quantile=0.99, + ) + floor_sql, _ = db.floor_query + assert "l.scraped_at" in floor_sql + assert "l.last_seen_at" not in floor_sql + + +def test_floor_rejects_invalid_staleness_column(monkeypatch: pytest.MonkeyPatch) -> None: + """Whitelist колонки работает и на пути пола — интерполяции чужого имени нет.""" + db = _FakeDB(floor_days=20.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, staleness_column="is_active", revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_health_gate_still_wins_over_floor(monkeypatch: pytest.MonkeyPatch) -> None: + """Нездоровый источник блокируется гейтом ДО того, как считается пол: + лишний тяжёлый запрос по мёртвому источнику не нужен.""" + db = _FakeDB(floor_days=74.3, confirmations=10) + out = _run( + db, + monkeypatch, + min_confirmations=500, + revisit_floor_quantile=0.99, + ) + assert out["skipped_unhealthy"] == 1 + assert db.floor_query[0] == "" + assert db.update_query[0] == "" + + +def test_floor_sql_is_psycopg_v3_safe() -> None: + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text) + assert "CAST(:revisit_quantile AS double precision)" in sql + assert "CAST(:health_window_days AS integer)" in sql + assert not re.search(r":\w+::", sql) + assert "UPDATE" not in sql.upper() + assert "DELETE" not in sql.upper() + + +def test_floor_only_counts_rows_the_sweep_actually_refound() -> None: + """Пол меряет выживших, а не «мы к ним не приходили»: свежесть обязана СДВИНУТЬСЯ + относительно предыдущего снимка, иначе разрыв нулевой и хвост схлопнется в ноль.""" + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text) + assert "l.last_seen_at > prev.last_seen_at" in sql + + +def test_default_quantile_is_high_enough_for_the_prod_case() -> None: + """Ниже 0.99 опускать нельзя без нового замера: именно на 0.99 прод-запрос даёт + 34.0 для cian/vtorichka, что накрывает возраст ложных снятий 30.3.""" + assert task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE >= 0.99 + assert _PROD_FLOORS["cian/vtorichka"][3] > _FALSE_KILL_AGE_MAX + + +def test_handler_wires_revisit_floor_from_schedule_params() -> None: + """Читаем исходник файлом: 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( "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE )' in flat + assert "revisit_floor_quantile=revisit_floor_quantile" in job diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py new file mode 100644 index 00000000..bdb6f976 --- /dev/null +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -0,0 +1,362 @@ +"""Разбор мёртвого кода #2674: подключить / удалить / задокументировать. + +Каждая правка эпика — тест, который краснеет без неё: + + подключено: + - houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница + не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»); + - domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules — + именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным; + - filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash. + + удалено (гейт против возврата): + - _phys_dedup_key / _extract_street_token — обёртки без прод-вызовов; + - asking_to_sold_ratios_tiered / asking_to_sold_tier_bounds — таблицы без + читателя и писателя; + - listings.merged_into, house_sources.raw_payload — колонки без писателя; + - v_data_quality.price_disagreements_count — показатель, который не мог быть + ненулевым. + + задокументировано: + - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — + сервис обязан сказать об этом вслух на старте. + +Без БД и сети: сессия замокана, SQL-миграции читаются как текст. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from unittest.mock import MagicMock, patch + +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" +SQL_DIR = TRADEIN / "backend" / "data" / "sql" +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: загрузчик ДОМ.РФ — оборванная проводка +# ───────────────────────────────────────────────────────────────────────────── + + +def test_domrf_loader_has_a_scheduler_handler() -> None: + """Без Handler'а загрузчик ДОМ.РФ был невызываем — этого и не хватало.""" + from app.services.product_handlers import build_product_handlers + + handlers = build_product_handlers(MagicMock()) + assert "domrf_kapremont_load" in handlers + + +def test_domrf_loader_is_seeded_into_schedules() -> None: + """Handler без строки расписания так же нем, как расписание без Handler'а.""" + sql = MIGRATION.read_text(encoding="utf-8") + assert "'domrf_kapremont_load'" in sql + assert "INSERT INTO scrape_schedules" in sql + # Недельный такт: реестр капремонта не меняется ежедневно, а прогон качает два zip. + assert '"interval_days": 7' in sql + + +def test_domrf_window_does_not_collide_with_matview_refresh() -> None: + """Окно ДОМ.РФ не должно совпадать с refresh_search_matview (03:00-04:00 UTC). + + Ревью #2689: планировщик берёт случайный момент внутри окна и гоняет источники + параллельно — общее окно с тем заданием, которое переносит year_built в поиск, + это подбрасывание монеты. Тест ловит откат окна обратно на 3. + """ + sql = MIGRATION.read_text(encoding="utf-8") + values = sql.split("'domrf_kapremont_load',", 1)[1].split(")", 1)[0] + tokens = [t.strip().rstrip(",") for t in values.splitlines()] + hours = [int(t) for t in tokens if t.isdigit()] + assert hours, "не нашли window_start_hour/window_end_hour в INSERT" + start, end = hours[0], hours[1] + matview_start, matview_end = 3, 4 # прод-значение scrape_schedules на 2026-08-06 + assert end <= matview_start or start >= matview_end, ( + f"окно {start}-{end} пересекается с refresh_search_matview " + f"{matview_start}-{matview_end}" + ) + + +def test_domrf_handler_reuses_loader_functions() -> None: + """Дизайн-инвариант product_handlers: job переиспользует боевое тело, не копирует.""" + src = (TRADEIN / "backend" / "app" / "services" / "product_handlers.py").read_text( + encoding="utf-8" + ) + body = src.split("_job_domrf_kapremont_load", 1)[1].split("# ──", 1)[0] + for fn in ( + "load_domrf_kapremont", + "backfill_houses_from_domrf", + "propagate_listings_year_from_houses", + ): + assert fn in body, f"{fn} не вызывается — загрузчик подключён лишь наполовину" + + +# ───────────────────────────────────────────────────────────────────────────── +# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали +# ───────────────────────────────────────────────────────────────────────────── + + +def test_filters_hash_read_from_sale_wrapper() -> None: + """Прод-форма ответа: ключи estimation.sale = {isError, filtersHash, data, isFetching}.""" + state = { + "user": {"isAuthenticated": True, "userId": 1}, + "estimation": { + "sale": { + "isError": False, + "isFetching": False, + "filtersHash": "96bba2876162e2b822f80eec", + "data": {"price": 9_000_000, "accuracy": 12}, + }, + "rent": {"data": {}}, + }, + } + assert _parse_valuation_state(state).filters_hash == "96bba2876162e2b822f80eec" + + +def test_filters_hash_absent_stays_none() -> None: + """Нет ключа → None. Со старым (вложенным) путём тест бы прошёл — он не про фикс.""" + state = {"estimation": {"sale": {"data": {"price": 1}}, "rent": {"data": {}}}} + assert _parse_valuation_state(state).filters_hash is None + + +def test_filters_hash_backfill_uses_the_same_path() -> None: + """Миграция достаёт хеш ровно оттуда же, откуда его теперь читает парсер.""" + sql = MIGRATION.read_text(encoding="utf-8") + assert "'{estimation,sale,filtersHash}'" in sql + assert "WHERE filters_hash IS NULL" in sql + + +# ───────────────────────────────────────────────────────────────────────────── +# Удалено: гейты против возврата +# ───────────────────────────────────────────────────────────────────────────── + + +def _live_python_sources() -> list[Path]: + """Боевой Python trade-in: app + scraper-kit + browser. Без тестов.""" + roots = [ + TRADEIN / "backend" / "app", + TRADEIN / "packages" / "scraper-kit" / "src", + TRADEIN / "browser", + ] + return [p for root in roots for p in root.rglob("*.py") if not p.name.startswith("test_")] + + +def test_dead_dedup_wrappers_are_gone() -> None: + """Обёртки без прод-вызовов (25 ссылок, все из тестов) не должны вернуться.""" + for name in ("_phys_dedup_key", "_extract_street_token"): + assert not hasattr(estimator, name), ( + f"{name} снова в estimator — эта обёртка описывала правило, " + "которого в боевом дедупе (_union_find_phys_dedup) нет" + ) + + +def test_dead_names_absent_from_live_code() -> None: + """Имена удалённых таблиц/колонок/показателей не упоминаются в боевом коде. + + SQL-миграции сознательно НЕ проверяем: 098/028/029/046 — исторические файлы, + переписывать их задним числом нельзя (пересборка с нуля идёт по ним). + """ + dead = [ + "asking_to_sold_ratios_tiered", + "asking_to_sold_tier_bounds", + "price_disagreements_count", + ] + offenders: list[str] = [] + for path in _live_python_sources(): + text = path.read_text(encoding="utf-8") + for name in dead: + if name in text: + offenders.append(f"{path.relative_to(REPO_ROOT)}: {name}") + assert not offenders, "удалённое снова упоминается: " + "; ".join(offenders) + + +def test_dropped_columns_have_no_python_writer() -> None: + """merged_into / house_sources.raw_payload: писателя не было и быть не должно.""" + offenders = [ + str(p.relative_to(REPO_ROOT)) + for p in _live_python_sources() + if "merged_into" in p.read_text(encoding="utf-8") + ] + assert not offenders, f"listings.merged_into снова упомянут: {offenders}" + + hs_writers = [ + p + for p in _live_python_sources() + if "INSERT INTO house_sources" in p.read_text(encoding="utf-8") + ] + assert hs_writers, "писатели house_sources исчезли — тест потерял смысл, проверь grep" + for path in hs_writers: + stmt = path.read_text(encoding="utf-8").split("INSERT INTO house_sources", 1)[1] + stmt = stmt.split("VALUES", 1)[0] + assert "raw_payload" not in stmt, f"{path} снова пишет удалённую колонку" + + +def test_migration_drops_exactly_what_was_declared_dead() -> None: + sql = MIGRATION.read_text(encoding="utf-8") + for stmt in ( + "DROP TABLE IF EXISTS asking_to_sold_ratios_tiered", + "DROP TABLE IF EXISTS asking_to_sold_tier_bounds", + "ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS merged_into", + "ALTER TABLE IF EXISTS house_sources DROP COLUMN IF EXISTS raw_payload", + "DROP INDEX IF EXISTS house_sources_raw_payload_gin_idx", + ): + assert stmt in sql, f"миграция не выполняет: {stmt}" + # Показатель убран из KPI-снимка, но сам view-источник оставлен как задел. + view_ddl = sql.split("CREATE OR REPLACE VIEW v_data_quality", 1)[1].split(";", 1)[0] + assert "price_disagreements_count" not in view_ddl + assert "COMMENT ON VIEW v_price_divergence" in sql + + +def test_price_divergence_is_documented_as_structurally_empty() -> None: + """Оставленный задел обязан говорить, чем он НЕ является сегодня.""" + sql = MIGRATION.read_text(encoding="utf-8") + comment = sql.split("COMMENT ON VIEW v_price_divergence IS", 1)[1].split(";", 1)[0] + assert ( + "match_or_create_listing" in comment + ), "комментарий не называет причину пустоты — без неё это просто «пока пусто»" + + +# ───────────────────────────────────────────────────────────────────────────── +# Задокументировано: мёртвая переменная окружения +# ───────────────────────────────────────────────────────────────────────────── + + +def test_browser_warns_about_retired_env() -> None: + """BROWSER_BLOCK_RESOURCES выставлен в трёх прод-контейнерах и ни на что не влияет. + + Сам browser/server.py тянет aiohttp+camoufox и в backend-окружении не + импортируется, поэтому проверяем исходник: переменная обязана быть в реестре + отставных И должна логироваться предупреждением на старте. + """ + src = (TRADEIN / "browser" / "server.py").read_text(encoding="utf-8") + assert "_RETIRED_ENV" in src + assert '"BROWSER_BLOCK_RESOURCES"' in src + assert "_warn_retired_env()" in src, "предупреждение не вызывается со старта" + # Переменная НЕ должна снова начать что-то менять — только предупреждать. + read_sites = re.findall(r'os\.environ(?:\.get)?[\[(]"BROWSER_BLOCK_RESOURCES"', src) + assert not read_sites, "BROWSER_BLOCK_RESOURCES снова читается как рабочий флаг" diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index 9c122cc4..39f869f3 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -1,10 +1,10 @@ """Offline unit tests for DomClickCitySweepCounters + DomClick parser. Legacy `run_domclick_city_sweep` orchestration behavior (sweep phases, cooperative -cancel, honest mark_done/mark_failed statuses) удалён вместе с +cancel, honest mark_done/mark_failed/mark_banned statuses) удалён вместе с `app.services.scrape_pipeline` (#2397 Part E1) — эквивалентная regression-coverage -теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_{done, -blocked_failed}` (kit `run_domclick_city_sweep`). +теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_*` +(kit `run_domclick_city_sweep`). `app.services.scrapers.domclick` удалён (#2397 Part E2, 0 runtime-импортёров) — `_map_item` ретаргетирован на kit `scraper_kit.providers.domclick.serp.DomClickScraper` @@ -120,7 +120,7 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey fake_fetcher = _FakeFetcher() - def _fake_build_browser_fetcher(config: object, source: str) -> _FakeFetcher: + def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher: assert source == "domclick" return fake_fetcher diff --git a/tradein-mvp/backend/tests/test_estimate_consent_gate.py b/tradein-mvp/backend/tests/test_estimate_consent_gate.py new file mode 100644 index 00000000..4dc9316c --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_consent_gate.py @@ -0,0 +1,319 @@ +"""ЭТАП 4 B2C launch — consent-before-save gate on trade_in_estimates (part A). + +Covers: + - require_consent=True + payload.consent missing/False -> HTTPException(422), + raised BEFORE geocode() is ever touched and BEFORE any db.execute call + (gate is literally the first statement in estimate_quality()). + - require_consent=True + payload.consent=True -> proceeds, and the eventual + INSERT carries consent=True / client_ip / policy version / text snapshot. + - require_consent=True + consent=True but geocode fails -> the + _empty_estimate() fallback INSERT *also* carries the same consent proof + (both places an address can reach trade_in_estimates are covered). + - require_consent defaults to False (regression guard): a bare + estimate_quality(payload, db) call -- exactly the shape used by ~90 other + estimator tests that don't care about auth/consent at all -- is completely + unaffected. This is the whole reason the gate keys off an explicit + keyword-only flag instead of `created_by is None`: the first version of + this gate DID key off created_by and broke 92 unrelated tests across the + estimator test suite (every one of them calls estimate_quality(payload, db) + with created_by defaulting to None, which used to mean nothing). + - the real (only) production caller, app/api/v1/trade_in.py::estimate(), + passes require_consent=(x_authenticated_user is None) -- verified via source + inspection, since exercising it live would require a full FastAPI app + fixture (rbac_guard is DB/env-heavy and out of scope for this offline test). + +Style mirrors tests/test_estimator_client_coords.py + test_estimator_event_loop_2207.py +(offline, db=MagicMock(), downstream helpers patched). +""" + +from __future__ import annotations + +import contextlib +import inspect +import os +import re +from pathlib import Path +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pytest +from fastapi import HTTPException + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_229 = _SQL_DIR / "229_trade_in_estimates_consent_proof.sql" + + +def _make_payload(**overrides: Any) -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + base: dict[str, Any] = dict(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1) + base.update(overrides) + return TradeInEstimateInput(**base) + + +def _make_fake_geo() -> Any: + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Тестовая, 1", + provider="nominatim", + ) + + +def _downstream_patches(geocode_mock: Any) -> tuple[Any, ...]: + """Offline mocks so estimate_quality runs to completion (mirrors 2207 test's set).""" + return ( + patch("app.services.estimator.geocode", new=geocode_mock), + 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=([], False, "W")), + patch("app.services.estimator._fetch_deals", return_value=[]), + 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=(None, None)), + ) + + +def _find_insert_call(db: MagicMock, table_marker: str) -> dict[str, Any]: + for call in db.execute.call_args_list: + stmt = call.args[0] + sql = str(getattr(stmt, "text", stmt)) + if f"INSERT INTO {table_marker}" in sql: + return call.args[1] + raise AssertionError( + f"no INSERT INTO {table_marker} call captured; calls={db.execute.call_args_list}" + ) + + +# ── require_consent=True, no consent -> 422 BEFORE any work ─────────────────── + + +def test_require_consent_without_payload_consent_raises_422_before_geocode() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # consent defaults to None + + async def _run() -> None: + with patch("app.services.estimator.geocode") as geocode_mock: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + geocode_mock.assert_not_called() + + anyio.run(_run) + assert not db.execute.called, "gate must precede ANY db write" + + +def test_require_consent_with_payload_consent_false_raises_422() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=False) + + async def _run() -> None: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + + anyio.run(_run) + assert not db.execute.called + + +# ── require_consent=True, consent given -> proceeds, proof persisted ────────── + + +def test_require_consent_with_payload_consent_persists_proof() -> None: + from app.services.estimator import _ESTIMATE_CONSENT_POLICY_VERSION as POLICY_VERSION + from app.services.estimator import _ESTIMATE_CONSENT_TEXT_SNAPSHOT as TEXT_SNAPSHOT + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=True) + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality( + payload, db, client_ip="203.0.113.9", require_consent=True + ) + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is True + assert params["client_ip"] == "203.0.113.9" + assert params["consent_policy_version"] == POLICY_VERSION + assert params["consent_text_snapshot"] == TEXT_SNAPSHOT + assert params["created_by"] is None + + +# ── require_consent=True, consent given, geocode fails -> _empty_estimate too ─ + + +def test_empty_estimate_fallback_persists_proof_when_consent_required() -> None: + from app.services.estimator import _ESTIMATE_CONSENT_POLICY_VERSION as POLICY_VERSION + from app.services.estimator import _ESTIMATE_CONSENT_TEXT_SNAPSHOT as TEXT_SNAPSHOT + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=True) + geocode_mock = AsyncMock(return_value=None) # geocode failure -> _empty_estimate path + + async def _run() -> Any: + with patch("app.services.estimator.geocode", new=geocode_mock): + return await estimate_quality( + payload, db, client_ip="198.51.100.4", require_consent=True + ) + + result = anyio.run(_run) + assert result.n_analogs == 0 + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is True + assert params["client_ip"] == "198.51.100.4" + assert params["consent_policy_version"] == POLICY_VERSION + assert params["consent_text_snapshot"] == TEXT_SNAPSHOT + + +def test_empty_estimate_fallback_gate_still_blocks_without_consent() -> None: + """Gate precedes _empty_estimate too -- geocode is never even reached.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent + + async def _run() -> None: + with patch("app.services.estimator.geocode") as geocode_mock: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + geocode_mock.assert_not_called() + + anyio.run(_run) + + +# ── require_consent defaults False -> zero blast radius on existing callers ─── + + +def test_require_consent_defaults_false() -> None: + from app.services.estimator import estimate_quality + + sig = inspect.signature(estimate_quality) + assert sig.parameters["require_consent"].default is False + assert sig.parameters["require_consent"].kind == inspect.Parameter.KEYWORD_ONLY + + +def test_bare_call_without_require_consent_is_unaffected() -> None: + """The exact call shape used by ~90 other estimator tests + (estimate_quality(payload, db), no created_by/require_consent at all) -- + must keep working with NO consent field on the payload whatsoever.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent field + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality(payload, db) + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is None + assert params["client_ip"] is None + assert params["consent_policy_version"] is None + assert params["consent_text_snapshot"] is None + + +# ── B2B pilot (created_by set, require_consent left False) -> unaffected ────── + + +def test_b2b_pilot_without_consent_field_unaffected() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent field at all -- mirrors real pilot frontend + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality(payload, db, created_by="kopylov") + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["created_by"] == "kopylov" + assert params["consent"] is None + assert params["client_ip"] is None + assert params["consent_policy_version"] is None + assert params["consent_text_snapshot"] is None + + +# ── production wiring: app/api/v1/trade_in.py passes require_consent correctly ─ + + +def test_api_handler_wires_require_consent_from_auth_header() -> None: + """Source-inspection guard: the ONLY production caller of estimate_quality + must derive require_consent from the ABSENCE of X-Authenticated-User, not + hardcode True/False. Cheaper and more robust than spinning up a full app + + rbac_guard fixture just to exercise one kwarg's wiring.""" + from app.api.v1 import trade_in as trade_in_module + + src = re.sub(r"\s+", " ", inspect.getsource(trade_in_module.estimate)) + assert "require_consent=x_authenticated_user is None" in src + + +# ── Migration 229 sanity ──────────────────────────────────────────────────────── + + +def test_migration_229_exists() -> None: + assert _MIGRATION_229.is_file(), f"missing migration: {_MIGRATION_229}" + + +def test_migration_229_is_transactional() -> None: + sql = _MIGRATION_229.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_229_is_idempotent() -> None: + sql = _MIGRATION_229.read_text("utf-8") + assert "ADD COLUMN IF NOT EXISTS consent" in sql + assert "ADD COLUMN IF NOT EXISTS client_ip" in sql + assert "CREATE INDEX IF NOT EXISTS" in sql + assert "pg_constraint" in sql # DO-block guard, not bare ADD CONSTRAINT + + +def test_migration_229_no_psycopg_trap() -> None: + sql = _MIGRATION_229.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +def test_migration_229_check_constraint_allows_null_or_true() -> None: + sql = _MIGRATION_229.read_text("utf-8") + assert "consent IS NULL OR consent IS TRUE" in sql diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 40234a3e..790fc31f 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -51,8 +51,19 @@ def trade_in_app() -> FastAPI: return application -def _make_estimate_row(created_by: str | None) -> SimpleNamespace: - """A trade_in_estimates row with the full column set the endpoints read.""" +def _make_estimate_row(created_by: str | None, retain_until: object = None) -> SimpleNamespace: + """A trade_in_estimates row with the full column set the endpoints read. + + retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every + row that existed before that migration; explicit param lets retention-gate + tests (see test_estimate_retention_gate.py) construct a paid row. + + relaxations/reliability (migration 255) default to the schema defaults + ('[]' / 'ok') -- matches every pre-migration row (no backfill). Revival + scenarios (a "dead" median_price<=0/NULL row) are covered separately in + test_estimate_revival.py with their own dedicated row builders, since this + fixture's downstream tests here all assume a "live" estimate. + """ from datetime import UTC, datetime, timedelta return SimpleNamespace( @@ -69,6 +80,7 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: sources_used=["avito"], data_freshness_minutes=10, expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=retain_until, address="ул. Тестовая, 1", lat=56.8, lon=60.6, @@ -93,6 +105,8 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: ratio_basis="per_rooms", created_by=created_by, created_at=datetime.now(tz=UTC), + relaxations=[], + reliability="ok", ) @@ -597,3 +611,116 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) headers={"X-Authenticated-User": "attacker"}, ) assert resp.status_code == 404 + + +# ── Payments retention: retention gate unification (retain_until, PR #2754) ── + + +def test_estimate_readable_sql_uses_disjunction() -> None: + """Single definition — OR retain_until, not a hand-copied expression.""" + from app.api.v1.trade_in import ESTIMATE_READABLE_SQL + + assert "expires_at > NOW()" in ESTIMATE_READABLE_SQL + assert "retain_until > NOW()" in ESTIMATE_READABLE_SQL + assert " OR " in ESTIMATE_READABLE_SQL + + +def test_get_estimate_sql_built_from_shared_constant() -> None: + """GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a + hand-copied literal — regression guard against the two gates drifting apart + again (that's exactly what happened before this PR: 404 here, 410 in /pdf).""" + import inspect + + from app.api.v1.trade_in import get_estimate + + src = inspect.getsource(get_estimate) + assert "ESTIMATE_READABLE_SQL" in src + assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant" + assert "retain_until" in src, "SELECT must also fetch retain_until" + + +def test_estimate_pdf_select_includes_retain_until_column() -> None: + import inspect + + from app.api.v1.trade_in import estimate_pdf + + assert "retain_until" in inspect.getsource(estimate_pdf) + + +@pytest.mark.parametrize( + ("expires_delta_hours", "retain_delta_days", "expected"), + [ + (12, None, True), # not expired, unpaid — current B2B/B2C behaviour, unchanged + (-1, None, False), # expired, unpaid — current behaviour (404/410), unchanged + (-1, 365, True), # expired but PAID — new: readable + (12, 365, True), # not expired AND paid — readable + (-1, -1, False), # expired, and the (hypothetical) retain_until also in the past + ], +) +def test_estimate_readable_truth_table( + expires_delta_hours: int, retain_delta_days: int | None, expected: bool +) -> None: + from datetime import UTC, datetime, timedelta + + from app.api.v1.trade_in import estimate_readable + + expires_at = datetime.now(tz=UTC) + timedelta(hours=expires_delta_hours) + retain_until = ( + datetime.now(tz=UTC) + timedelta(days=retain_delta_days) + if retain_delta_days is not None + else None + ) + assert estimate_readable(expires_at, retain_until) is expected + + +def test_pdf_expired_but_paid_returns_200(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until in the future → PDF still downloads + (200). Exactly the scenario PR-D1 exists for: a paid report must outlive + the 24h expires_at link TTL.""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = datetime.now(tz=UTC) + timedelta(days=300) + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + + +def test_pdf_expired_unpaid_returns_410_without_ttl_text(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until NULL (unpaid, unchanged behaviour) → + 410, and the detail text no longer claims a specific '24h TTL' (would be a + lie now that retain_until exists for paid rows).""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 410 + assert resp.json()["detail"] == "estimate expired" + assert "24h" not in resp.json()["detail"] + assert "TTL" not in resp.json()["detail"] + + +def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI) -> None: + """Response schema exposes retain_until (nullable) — schemas/trade_in.py.""" + row = _make_estimate_row(created_by="kopylov") # retain_until defaults to None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["retain_until"] is None diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py new file mode 100644 index 00000000..e1758d29 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -0,0 +1,598 @@ +"""Tests for GET /estimate/{id} revival of "dead" (median_price<=0/NULL) rows. + +Incident 2026-08-10: a customer opened a saved estimate link and saw +«НЕДОСТАТОЧНО ДАННЫХ» — the row was created BEFORE the estimator fix +(#oblast-E/#oblast-F, PR #2823/#2825) and is permanently stuck at +median_price=0, even though the same address/params now compute a real +price. app.api.v1.trade_in::_try_revive_dead_estimate recomputes such a row +in place (same id/link) via the same estimate_quality() path as POST +/estimate. Also covers migration 255 (relaxations/reliability persistence). + +Реальная БД не нужна: DB + get_role + estimate_quality мокируются, mirroring +test_estimate_idor.py's approach (self-contained, no cross-file fixture +imports — this repo has no precedent for importing fixtures across +tests/test_*.py modules, only from tests/support/). + +DB mock dispatches by SQL substring (not call-position): get_estimate's +existing rehydrate path calls the real (non-stubbed) _resolve_target_house_id +helper, which itself fires 1-2 incidental `SELECT id FROM houses` queries +whenever the revival attempt does NOT short-circuit with an early return — +hand-counting positional side_effect entries around that would be brittle. +""" + +from __future__ import annotations + +import inspect +import os +import sys +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +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. +_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 + +_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222" +_TEMP_ID = "33333333-3333-3333-3333-333333333333" + + +@pytest.fixture(autouse=True) +def _restore_get_role(): + """Restore app.core.auth.get_role after each test (mirror test_estimate_idor).""" + from app.core import auth as auth_mod + + original = auth_mod.get_role + yield + auth_mod.get_role = original + + +@pytest.fixture() +def trade_in_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router.""" + from app.api.v1 import trade_in as trade_in_module + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + return application + + +def _make_dead_row( + *, + median_price: int | None = 0, + house_type: str | None = None, + repair_state: str | None = None, + relaxations: list[str] | None = None, + reliability: str = "ok", +) -> SimpleNamespace: + """A trade_in_estimates row in the "dead" state (incident 2026-08-10). + + house_type/repair_state default to None (valid TradeInEstimateInput input — + most revival tests need a row that round-trips through pydantic cleanly); + a dedicated test passes legacy Russian literal values to exercise the + "invalid persisted value" graceful-degrade path. + """ + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=median_price, + range_low=0, + range_high=0, + median_price_per_m2=0, + confidence="low", + confidence_explanation="Рядом найдено недостаточно объявлений (4 шт.)", + n_analogs=0, + analogs=[], + actual_deals=[], + sources_used=[], + data_freshness_minutes=None, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="г Екатеринбург, ул Академика Парина, д 46, к 5", + lat=56.8519, + lon=60.6122, + area_m2=23.1, + rooms=1, + floor=5, + total_floors=16, + year_built=2018, + house_type=house_type, + repair_state=repair_state, + has_balcony=None, + canonical_address=None, + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=None, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="kopylov", + created_at=datetime(2026, 5, 29, tzinfo=UTC), + relaxations=relaxations or [], + reliability=reliability, + ) + + +def _make_live_row( + *, relaxations: list[str] | None = None, reliability: str = "ok" +) -> SimpleNamespace: + """A "live" row (median_price>0) — revival must never touch it.""" + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=4_031_157, + range_low=3_700_000, + range_high=4_300_000, + median_price_per_m2=174_000, + confidence="medium", + confidence_explanation="Найдено 39 аналогов", + n_analogs=39, + analogs=[], + actual_deals=[], + sources_used=["avito", "rosreestr"], + data_freshness_minutes=15, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="г Екатеринбург, ул Академика Парина, д 46, к 5", + lat=56.8519, + lon=60.6122, + area_m2=23.1, + rooms=1, + floor=5, + total_floors=16, + year_built=2018, + house_type=None, + repair_state=None, + has_balcony=None, + canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=0, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="kopylov", + created_at=datetime.now(tz=UTC), + relaxations=relaxations or [], + reliability=reliability, + ) + + +def _fake_revived_result(**overrides): + """A canned AggregatedEstimate mimicking a successful estimate_quality() call.""" + from app.schemas.trade_in import AggregatedEstimate + + defaults = dict( + estimate_id=_TEMP_ID, + median_price_rub=4_031_157, + range_low_rub=3_700_000, + range_high_rub=4_300_000, + median_price_per_m2=174_000, + confidence="medium", + confidence_explanation="Найдено 39 аналогов", + n_analogs=39, + period_months=12, + analogs=[], + actual_deals=[], + expires_at=datetime.now(tz=UTC) + timedelta(hours=24), + target_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + target_lat=56.8519, + target_lon=60.6122, + sources_used=["avito", "rosreestr"], + data_freshness_minutes=15, + canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + relaxations=["снят фильтр по году постройки", "учтены студии"], + reliability="low", + created_at=datetime.now(tz=UTC), + ) + defaults.update(overrides) + return AggregatedEstimate(**defaults) + + +def _dispatch_db(row: object, claim_result: object = None) -> MagicMock: + """DB session mock dispatching fetchone() results by SQL substring. + + - initial GET SELECT ("SELECT id, median_price ...") -> row + - revival claim UPDATE ("SET revival_attempted_at") -> claim_result + - everything else (houses lookup, persist UPDATE, DELETE, + avito_imv UPDATE — none of which .fetchone() in real code + except the two above, but MagicMock tolerates the unused + call either way) -> None + + call_args_list still records every call in order regardless of dispatch, + so tests can assert on it directly (grep by substring) without needing to + hand-count incidental queries fired by _resolve_target_house_id. + """ + db = MagicMock() + + def _execute(clause, params=None, *_a, **_k): + sql = getattr(clause, "text", str(clause)) + result = MagicMock() + if "SET revival_attempted_at" in sql: + result.fetchone.return_value = claim_result + elif "SELECT id, median_price" in sql: + result.fetchone.return_value = row + else: + result.fetchone.return_value = None + return result + + db.execute.side_effect = _execute + return db + + +def _calls_containing(db: MagicMock, needle: str) -> list: + return [c for c in db.execute.call_args_list if needle in getattr(c.args[0], "text", "")] + + +def _client_with(app: FastAPI, db_mock: MagicMock, role: str = "pilot") -> TestClient: + from app.core.db import get_db + + def _override_db(): + yield db_mock + + app.dependency_overrides[get_db] = _override_db + auth_mod = sys.modules["app.core.auth"] + auth_mod.get_role = lambda _u: role # type: ignore[assignment] + return TestClient(app) + + +@pytest.fixture() +def _estimator_stub(): + """Replaces app.services.estimator with a SimpleNamespace stub. + + Mirrors test_estimate_idor.py::_stub_precision_and_pdf, plus an + `estimate_quality` async attribute (revival's own lazy import target). + Individual tests overwrite `estimate_quality` per-scenario. + """ + real_estimator = sys.modules.get("app.services.estimator") + + async def _default_estimate_quality(*_a, **_k): # pragma: no cover — overridden per test + raise AssertionError("estimate_quality stub not configured for this test") + + stub = SimpleNamespace( + _qc_geo_to_precision=lambda _qc: None, + _fetch_price_trend=lambda *a, **k: None, + _fetch_dkp_corridor=lambda *a, **k: None, + _fetch_house_imv_anchor=lambda *a, **k: None, + _resolve_target_city=lambda *a, **k: None, + _cv_from_ppm2=lambda *a, **k: None, + _source_counts=lambda *a, **k: {}, + _canonical_sources=lambda *a, **k: [], + estimate_quality=_default_estimate_quality, + ) + sys.modules["app.services.estimator"] = stub # type: ignore[assignment] + yield stub + if real_estimator is not None: + sys.modules["app.services.estimator"] = real_estimator + else: + sys.modules.pop("app.services.estimator", None) + + +# ── Revival success ─────────────────────────────────────────────────────── + + +def test_dead_row_revives_and_updates_db( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Dead row (median_price=0) → recomputed, written to the SAME id, returned fresh. + + Success path returns early (before the pre-existing rehydrate block), so + the call count is exactly the 5 documented in _try_revive_dead_estimate's + docstring: claim, avito_imv relink, persist UPDATE, DELETE temp — plus the + initial SELECT. + """ + + async def _fake_estimate_quality(payload, db, **kwargs): + assert payload.address.startswith("г Екатеринбург") + assert payload.rooms == 1 + assert payload.radius_m is None # never persisted — default cascade + return _fake_revived_result() + + _estimator_stub.estimate_quality = _fake_estimate_quality + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + # id/link contract: response carries the ORIGINAL id, not the temp uuid + # estimate_quality() minted internally. + assert body["estimate_id"] == _ESTIMATE_ID + assert body["median_price_rub"] == 4_031_157 + assert body["n_analogs"] == 39 + assert body["insufficient_data"] is False + assert body["reliability"] == "low" + assert "снят фильтр по году постройки" in body["relaxations"] + + assert len(db.execute.call_args_list) == 5 + persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET") + assert len(persist_calls) == 1 + persist_params = persist_calls[0].args[1] + assert persist_params["id"] == _ESTIMATE_ID + assert persist_params["median_price"] == 4_031_157 + assert persist_params["reliability"] == "low" + delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates") + assert len(delete_calls) == 1 + assert delete_calls[0].args[1]["id"] == _TEMP_ID + + +def test_dead_row_revival_preserves_created_at( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """#incident-2026-08-11: created_at is the client's ORIGINAL request date + (printed on /history, see app/schemas/trade_in.py:317-318) — revival must + not clobber it with the throwaway temp row's NOW(). Regression for the + prod incident (estimate ff421062-...: created_at jumped from the original + 2026-08-10 12:54:47 to the revival moment 2026-08-11 04:30:03). + + Also asserts: (a) the persist UPDATE never sets created_at at all — the + fix removes the column from SET, it doesn't just overwrite it with the + right value; (b) migration 256's revival_completed_at IS stamped, as the + separate "when did revival last succeed" audit trail; (c) the JSON + response mirrors the original created_at, not the temp result's. + """ + _original_created_at = datetime(2026, 5, 29, tzinfo=UTC) + + async def _fake_estimate_quality(payload, db, **kwargs): + # The temp row estimate_quality() mints internally always carries + # NOW() as its created_at — deliberately far from the original, so a + # regression (copying result.created_at through) is unmissable. + return _fake_revived_result(created_at=datetime(2026, 8, 11, 4, 30, 3, tzinfo=UTC)) + + _estimator_stub.estimate_quality = _fake_estimate_quality + + row = _make_dead_row() + row.created_at = _original_created_at + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["created_at"] == "2026-05-29T00:00:00Z" + + persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET") + assert len(persist_calls) == 1 + persist_sql = persist_calls[0].args[0].text + persist_params = persist_calls[0].args[1] + assert "created_at" not in persist_sql + assert "created_at" not in persist_params + assert "revival_completed_at = NOW()" in persist_sql + # Input-snapshot / TTL columns (what the client originally asked for and + # for how long the row is retained) are likewise not recompute outputs — + # untouched by the revival persist UPDATE. + # NB: "address" is checked via persist_params only (not persist_sql) — + # canonical_address (a legitimate recompute output) ends in "address =", + # which would false-positive a substring check against the raw SQL text. + for protected in ("expires_at", "retain_until", "created_by"): + assert f"{protected} =" not in persist_sql + assert protected not in persist_params + assert "address" not in persist_params + + +# ── Live row is never touched ──────────────────────────────────────────── + + +def test_live_row_never_triggers_revival( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """median_price>0 → revival branch skipped entirely: estimate_quality is + never called and no claim UPDATE fires — the saved price is untouched.""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be called for a live row") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_live_row() + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["median_price_rub"] == 4_031_157 + assert _calls_containing(db, "SET revival_attempted_at") == [] + + +# ── Throttle / anti-storm ──────────────────────────────────────────────── + + +def test_dead_row_throttled_does_not_recompute( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Claim UPDATE returns no row (recent attempt / lost race) → no recompute, + honest still-dead response, no 500, exactly one claim attempt (no retry + loop within the same request).""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be called when throttled") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=None) # throttled: WHERE matched nothing + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["insufficient_data"] is True + assert body["median_price_rub"] == 0 + assert len(_calls_containing(db, "SET revival_attempted_at")) == 1 + + +# ── Recompute error degrades gracefully (no 500) ───────────────────────── + + +def test_revival_recompute_exception_falls_back_without_500( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + async def _raises(*_a, **_k): + raise RuntimeError("geocode timeout") + + _estimator_stub.estimate_quality = _raises + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + # exception happened AFTER the claim — throttle was still recorded once. + assert len(_calls_containing(db, "SET revival_attempted_at")) == 1 + # ...but nothing was written back to the row (no persist UPDATE fired). + assert _calls_containing(db, "UPDATE trade_in_estimates SET") == [] + + +def test_revival_still_zero_falls_back_without_500( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Recompute runs but still finds nothing (median_price_rub=0) — honest + insufficient_data, temp throwaway row cleaned up, no crash.""" + + async def _still_empty(*_a, **_k): + return _fake_revived_result( + median_price_rub=0, + range_low_rub=0, + range_high_rub=0, + median_price_per_m2=0, + n_analogs=0, + confidence="low", + ) + + _estimator_stub.estimate_quality = _still_empty + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates") + assert len(delete_calls) == 1 + assert delete_calls[0].args[1]["id"] == _TEMP_ID + # the still-dead ORIGINAL row was never overwritten with (fresh) zeros. + assert _calls_containing(db, "UPDATE trade_in_estimates SET") == [] + + +def test_revival_invalid_persisted_house_type_falls_back_gracefully( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Legacy row with a house_type outside the current Literal set — payload + reconstruction itself raises (pydantic ValidationError), caught the same + way as any other recompute failure. No 500.""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be reached — payload build fails first") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_dead_row(house_type="монолит", repair_state="хороший") + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + + +# ── relaxations/reliability round-trip (migration 255) ─────────────────── + + +def test_relaxations_reliability_roundtrip_on_get( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """A live row with persisted relaxations/reliability surfaces them + byte-for-byte on GET — the red "точность снижена" banner survives + reopening a saved link (previously always reset to ok/[]).""" + row = _make_live_row( + relaxations=["радиус расширен до 2000 м", "площадь ±25%"], reliability="low" + ) + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["reliability"] == "low" + assert body["relaxations"] == ["радиус расширен до 2000 м", "площадь ±25%"] + + +def test_get_estimate_defaults_relaxations_reliability_when_row_lacks_columns( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Defensive getattr fallback: a row/mock without relaxations/reliability + attrs (e.g. a stale test double) degrades to schema defaults, not a crash.""" + row = _make_live_row() + del row.relaxations + del row.reliability + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["reliability"] == "ok" + assert body["relaxations"] == [] + + +# ── estimator.py persists relaxations/reliability on (re)compute ───────── + + +def test_estimate_quality_insert_persists_relaxations_reliability() -> None: + """Source guard: the main POST-path INSERT must write relaxations/ + reliability, not just return them in the response — regression guard + against the exact gap this migration closes (PR #2823 open follow-up).""" + from app.services import estimator + + src = inspect.getsource(estimator.estimate_quality) + assert "relaxations_json" in src + assert '"reliability": reliability' in src + + +def test_empty_estimate_persists_relaxations_reliability() -> None: + """_empty_estimate's INSERT must also set reliability='very_low' (mirrors + the Python object it returns) rather than silently defaulting to 'ok'.""" + from app.services import estimator + + src = inspect.getsource(estimator._empty_estimate) + assert "relaxations, reliability" in src + assert "'very_low'" in src diff --git a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py new file mode 100644 index 00000000..16815488 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py @@ -0,0 +1,263 @@ +"""#2827 — confidence↔reliability consistency (live-prod smoke, 2026-08-11). + +`confidence` (`_compute_confidence` — unique-address/IQR metric) and +`reliability` (`estimate_quality` — n_analogs-bucket + #oblast-F relaxation- +cascade metric, see #2823) were computed fully independently. The cascade's +room-adjacency/freshness/novostroyki steps never set `fallback_used`, so +`_compute_confidence` stayed blind to them and could keep scoring a wide, +tight-IQR (post-relaxation) sample as "high", while `reliability` — which DOES +see the relaxations — honestly read "low". + +Live prod repro (customer address, rooms=1, 23.1 m², radius=2000): + n_analogs: 39 + confidence: high + reliability: low + relaxations: ['снят фильтр по году постройки', 'учтены студии', 'площадь ±25%'] + +A client could see a "высокая уверенность" badge next to a "точность снижена" +banner on the SAME estimate — exactly the class of contradiction cleaned up +from the PDF/counters earlier in this cycle (#2824-adjacent work), now leaking +through the two headline confidence signals themselves. + +Fix: `_cap_confidence_by_reliability` (estimator.py), applied ONCE in +`estimate_quality` right after both `confidence` and `reliability` are final +— NOT spread across the #oblast-F cascade steps. Rule: + reliability == 'very_low' → confidence forced to 'low' + reliability == 'low' → confidence capped at 'medium' + reliability == 'ok' → confidence untouched (common case, unaffected) + +Two layers: + 1. `_cap_confidence_by_reliability` direct unit tests — the 3 rules in + isolation, no DB/estimate_quality overhead. + 2. `estimate_quality` integration tests — the prod repro shape (confidence + downgraded + confidence_explanation restructured to LEAD with the + accuracy-reduced summary) and the byte-identical-when-unrelaxed control. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.estimator import _cap_confidence_by_reliability +from app.services.geocoder import GeocodeResult + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 1 — `_cap_confidence_by_reliability` direct unit tests +# ───────────────────────────────────────────────────────────────────────────── + + +def test_very_low_reliability_forces_confidence_low() -> None: + """reliability == 'very_low' → confidence forced to 'low', regardless of + what _compute_confidence originally scored.""" + for original in ("high", "medium", "low"): + assert ( + _cap_confidence_by_reliability(original, "very_low") == "low" + ), f"original={original!r} must be forced to 'low' under very_low reliability" + + +def test_low_reliability_caps_confidence_at_medium() -> None: + """reliability == 'low' → confidence capped at 'medium': 'high' is pulled + down to 'medium'; 'medium'/'low' pass through unchanged (already <= cap).""" + assert _cap_confidence_by_reliability("high", "low") == "medium" + assert _cap_confidence_by_reliability("medium", "low") == "medium" + assert _cap_confidence_by_reliability("low", "low") == "low" + + +def test_ok_reliability_leaves_confidence_untouched() -> None: + """reliability == 'ok' — the common, unrelaxed case — must NOT change + confidence at all (byte-identical to pre-#2827 behaviour).""" + for original in ("high", "medium", "low"): + assert _cap_confidence_by_reliability(original, "ok") == original + + +def test_cap_never_raises_confidence() -> None: + """Sanity: the cap only lowers/holds — never raises 'low' to something + higher under any reliability value.""" + for reliability in ("ok", "low", "very_low"): + assert _cap_confidence_by_reliability("low", reliability) == "low" + + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) +# ───────────────────────────────────────────────────────────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Академика Парина, 46/5", + provider="nominatim", + ) + + +def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 23.1) -> dict[str, Any]: + return { + "source": "avito", + "source_url": f"https://avito.ru/offer/{address}", + "address": address, + "lat": 56.838, + "lon": 60.595, + "rooms": 1, + "area_m2": area_m2, + "floor": 5, + "total_floors": 9, + "price_rub": price_per_m2 * area_m2, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 10, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 150.0, + "relevance_score": 0.1, + } + + +def _tight_price_pool(n: int, base_ppm2: float = 195_000.0) -> list[dict[str, Any]]: + """n listings, distinct addresses, tightly clustered price/m² (~±2%) — + engineered to clear _compute_confidence's 'high' bar (unique_addr_count>=7 + AND iqr_pct<0.15) once assembled, exactly like the live prod repro (n=39, + confidence='high' pre-fix).""" + return [ + _make_listing( + price_per_m2=base_ppm2 + (i - n / 2) * 200, + address=f"ул. Академика Парина, {i + 1}", + ) + for i in range(n) + ] + + +def _payload_with_year() -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="ЕКБ, ул. Академика Парина, 46/5", + area_m2=23.1, + rooms=1, + floor=5, + total_floors=9, + year_built=2010, + city_hint="Екатеринбург", + radius_m=2000, + ) + + +def _run_estimate( + *, + payload: Any, + fetch_analogs_side_effect: Any, + dkp_raw: dict[str, Any] | None = None, +) -> Any: + from app.services.estimator import estimate_quality + + db = MagicMock() + geo = _geo() + + async def _run() -> Any: + with ( + 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", + side_effect=fetch_analogs_side_effect, + ), + patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), + patch("app.services.estimator._fetch_deals", return_value=[]), + 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._fetch_dkp_corridor", return_value=dkp_raw), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_e2e_prod_repro_high_confidence_low_reliability_gets_capped() -> None: + """Live prod repro shape: cohort (year_built) filter gets dropped by the + Tier-0 cascade (empty cohort-call → fallback), landing on a 39-listing, + tight-price, 39-unique-address pool — a healthy sample that + `_compute_confidence` would score 'high' on its own. Because a relaxation + WAS applied to get there, `reliability` reads 'low' — the cap must pull + `confidence` down to 'medium' (not leave it at the contradictory 'high'), + and `confidence_explanation` must LEAD with the accuracy-reduced summary.""" + pool = _tight_price_pool(39) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + # Tier 0 (with cohort filter) → empty, forces the "drop cohort" fallback. + if kwargs.get("cohort_year_min") is not None: + return [], False, "W" + # Every subsequent (no-cohort) call → the full healthy pool. + return list(pool), False, "W" + + est = _run_estimate(payload=_payload_with_year(), fetch_analogs_side_effect=_fetch_analogs_stub) + + assert est.n_analogs == 39 + assert est.reliability == "low" + assert "снят фильтр по году постройки" in est.relaxations + assert est.confidence == "medium", ( + f"confidence={est.confidence!r} must be capped to 'medium' under reliability='low' " + "(was 'high' pre-#2827 — the exact prod contradiction)" + ) + explanation = est.confidence_explanation + assert explanation is not None + assert explanation.startswith( + "Оценка построена с расширенными параметрами подбора — точность снижена." + ), f"explanation must LEAD with the accuracy-reduced summary, got: {explanation!r}" + assert "Найдено 39 аналогов" in explanation, "original detail must be preserved" + assert "Применены послабления подбора: снят фильтр по году постройки." in explanation + + +def test_e2e_unrelaxed_estimate_is_byte_identical_control() -> None: + """#3 (task spec): no relaxations, reliability=='ok' → confidence and + confidence_explanation must be UNCHANGED by #2827 — this is the common + path most estimates take, and it must not regress.""" + pool = _tight_price_pool(39) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + # No cohort filter this time (payload has no year_built) — Tier 0 is + # skipped outright (cohort_range is None), so no relaxation fires. + return list(pool), False, "W" + + from app.schemas.trade_in import TradeInEstimateInput + + payload = TradeInEstimateInput( + address="ЕКБ, ул. Академика Парина, 46/5", + area_m2=23.1, + rooms=1, + floor=5, + total_floors=9, + city_hint="Екатеринбург", + ) + est = _run_estimate(payload=payload, fetch_analogs_side_effect=_fetch_analogs_stub) + + assert est.n_analogs == 39 + assert est.relaxations == [] + assert est.reliability == "ok" + assert est.confidence == "high", "unrelaxed healthy sample must keep its real confidence" + assert est.confidence_explanation is not None + assert not est.confidence_explanation.startswith("Оценка построена с расширенными параметрами") + assert "Применены послабления подбора" not in est.confidence_explanation + assert est.confidence_explanation.startswith("Найдено 39 аналогов"), ( + "unrelaxed explanation must keep its original (pre-#2827) leading sentence, got: " + f"{est.confidence_explanation!r}" + ) diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 4492cbad..4564c412 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -1,7 +1,7 @@ """Unit tests for cross-source physical dedup of analogs (#2087 H4). Проверяет: - - физический ключ (_phys_dedup_key) — что образует / не образует; + - композиты физического ключа (_lot_dedup_components) — что образуют / не образуют; - _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя (свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично; - выбор представителя детерминирован (freshest scraped_at). @@ -21,6 +21,16 @@ import pytest from app.services import estimator +def _street_token(addr: str | None) -> str: + """Уличный токен из ЖИВОЙ функции разбора адреса. + + #2674: раньше тут звался `estimator._extract_street_token` — однострочная обёртка, + которую прод не вызывал ни разу. Обёртку убрали; хелпер живёт в тестах, где ему и + место, а проверяется ровно тот `_parse_street_house`, что крутится в проде. + """ + return estimator._parse_street_house(addr)[0] + + def _lot( *, source: str, @@ -48,45 +58,56 @@ def _lot( # --------------------------------------------------------------------------- # -# _phys_dedup_key +# _lot_dedup_components — композиты физического ключа +# +# #2674: раньше эти пять тестов дёргали `estimator._phys_dedup_key` — обёртку без +# единого прод-вызова, которая возвращала `cad_key or street_key`. Прод так ключ НЕ +# строит: _union_find_phys_dedup держит ОБА композита и сливает по любому совпадению +# (с guard'ами на разные кадастры / разные номера домов). Обёртку убрали, тесты +# переведены на живую _lot_dedup_components → (cad_s, house_no, cad_key, street_key). # --------------------------------------------------------------------------- # def test_phys_key_prefers_cadastral_over_address() -> None: lot = _lot(source="avito", address="ул. Ленина 5", cad="66:41:0601025:94") - key = estimator._phys_dedup_key(lot) - assert key is not None - assert key[0] == "66:41:0601025:94" # building = cad, не address + _cad_s, _house, cad_key, street_key = estimator._lot_dedup_components(lot) + assert cad_key is not None + assert cad_key[0] == "66:41:0601025:94" # building = cad, не address + # Street-композит тоже строится — прод сливает по ЛЮБОМУ из двух, не по одному. + assert street_key is not None and street_key[0] == "ленина" def test_phys_key_normalizes_address_when_no_cad() -> None: # Тот же адрес, разное форматирование (регистр/пунктуация/пробелы) — # punctuation-only нормализация схлопывает их к одному building-компоненту. - a = estimator._phys_dedup_key(_lot(source="avito", address="Екатеринбург, ул. Ленина, д. 5")) - b = estimator._phys_dedup_key(_lot(source="cian", address="екатеринбург ул ленина д 5")) - assert a is not None and b is not None - assert a[0] == b[0] + a = estimator._lot_dedup_components( + _lot(source="avito", address="Екатеринбург, ул. Ленина, д. 5") + ) + b = estimator._lot_dedup_components(_lot(source="cian", address="екатеринбург ул ленина д 5")) + assert a[3] is not None and b[3] is not None + assert a[3][0] == b[3][0] def test_phys_key_different_streets_do_not_merge() -> None: # Токенное отличие (разная улица) НЕ должно сливаться — защита от ложного merge. - a = estimator._phys_dedup_key(_lot(source="avito", address="ул. Ленина 5")) - b = estimator._phys_dedup_key(_lot(source="cian", address="ул. Мира 5")) - assert a is not None and b is not None - assert a[0] != b[0] + a = estimator._lot_dedup_components(_lot(source="avito", address="ул. Ленина 5")) + b = estimator._lot_dedup_components(_lot(source="cian", address="ул. Мира 5")) + assert a[3] is not None and b[3] is not None + assert a[3][0] != b[3][0] def test_phys_key_none_without_area_or_price() -> None: - assert estimator._phys_dedup_key(_lot(source="avito", area=0)) is None - assert estimator._phys_dedup_key(_lot(source="avito", price=0)) is None + for lot in (_lot(source="avito", area=0), _lot(source="avito", price=0)): + _cad_s, _house, cad_key, street_key = estimator._lot_dedup_components(lot) + assert cad_key is None and street_key is None def test_phys_key_area_bucket_tolerates_rounding() -> None: # 66.9 и 67.0 → один и тот же area_bucket (round → 67) - a = estimator._phys_dedup_key(_lot(source="avito", area=66.9)) - b = estimator._phys_dedup_key(_lot(source="cian", area=67.0)) - assert a is not None and b is not None - assert a[2] == b[2] == 67 + a = estimator._lot_dedup_components(_lot(source="avito", area=66.9)) + b = estimator._lot_dedup_components(_lot(source="cian", area=67.0)) + assert a[3] is not None and b[3] is not None + assert a[3][2] == b[3][2] == 67 # --------------------------------------------------------------------------- # @@ -188,20 +209,20 @@ def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> def test_extract_street_token_across_source_formats() -> None: # Три РЕАЛЬНЫХ прод-формата одного дома (ул. Сыромолотова) → один токен. - cian = estimator._extract_street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") - domklik = estimator._extract_street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") - yandex = estimator._extract_street_token("Екатеринбург, улица Сыромолотова, 11") + cian = _street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") + domklik = _street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") + yandex = _street_token("Екатеринбург, улица Сыромолотова, 11") assert cian == domklik == yandex == "сыромолотова" def test_extract_street_token_strips_house_and_noise() -> None: - assert estimator._extract_street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина" - assert estimator._extract_street_token("проспект Космонавтов 15") == "космонавтов" - assert estimator._extract_street_token("пер. Банковский, 8а") == "банковский" + assert _street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина" + assert _street_token("проспект Космонавтов 15") == "космонавтов" + assert _street_token("пер. Банковский, 8а") == "банковский" # Нет типа улицы → распознать нельзя → пусто (безопасный fallback, лот уникален). - assert estimator._extract_street_token("Екатеринбург, ЖК Дружба") == "" - assert estimator._extract_street_token("") == "" - assert estimator._extract_street_token(None) == "" + assert _street_token("Екатеринбург, ЖК Дружба") == "" + assert _street_token("") == "" + assert _street_token(None) == "" def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None: @@ -325,8 +346,8 @@ def test_parse_numbered_streets_and_house_no() -> None: def test_extract_street_token_numbered_not_degraded() -> None: # Ведущая цифра нумерованной улицы разрешена, номер дома — нет. - assert estimator._extract_street_token("улица 8 Марта, 46") == "8 марта" - assert estimator._extract_street_token("ул. Белинского 86") == "белинского" + assert _street_token("улица 8 Марта, 46") == "8 марта" + assert _street_token("ул. Белинского 86") == "белинского" def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None: @@ -458,9 +479,9 @@ def test_parse_street_house_branch1_wins_over_mkr_noise() -> None: def test_extract_street_token_extended_same_token_across_formats() -> None: # Все три source-формата дома «Рассветная» → один и тот же токен → склейка. - domklik = estimator._extract_street_token("Рассветная улица, 6 к1") - avito = estimator._extract_street_token("Рассветная ул.,6к1") - cian = estimator._extract_street_token("улица Рассветная, 6к1") + domklik = _street_token("Рассветная улица, 6 к1") + avito = _street_token("Рассветная ул.,6к1") + cian = _street_token("улица Рассветная, 6к1") assert domklik == avito == cian == "рассветная" diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py index 3ae13ef7..a4033a0f 100644 --- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py +++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py @@ -1,22 +1,55 @@ -"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02). +"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority +RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product +decision, 2026-08-10). -Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings → -headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring -street in the same town swung ±66% on 1-2 different random listings. Каменск- -Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match -either, with no honest refusal surfaced. Первоуральск (0 listings) already fell -back to the (pre-existing) ДКП deals-headline fallback correctly — this gate -routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME, -already-tested path instead of trusting a 1-4-lot median as the headline. +History: + 1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1) + listings sample to a literal zero, forcing the anchor/#oblast-D-deals- + fallback/insufficient_data chain to take over — motivated by a live + Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of + 54 126 ₽/м²). + 2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE — + a thin sample always kept its own median, even when a much more reliable + deals corridor was available. That accidentally REOPENED the exact Серов + bug #oblast-E existed to close. + 3. #oblast-E priority RESTORED (2026-08-10, same day, product correction): + "никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3 + объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in + `_price_from_inputs`'s gate: + - n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected). + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor + exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) → + listings aggregate suppressed to zero, headline ceded to the + #oblast-D deals-headline-fallback chain (original #oblast-E + behaviour, restored). `PricingResult.deals_headline_due_to_thin_ + listings=True` — estimate_quality() adds relaxation label "оценка по + сделкам — мало объявлений рядом" and caps reliability at 'low'. + Listings display cards are NOT hidden (unlike original #oblast-E) — + `listings_clean` stays intact and estimate_quality() still surfaces + them as context even though they no longer drive n_analogs/median. + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor → + #oblast-F: keep the real thin median (never refuse outright). + Real refusal ("недостаточно данных") now happens ONLY at genuine n=0 + (no listings AND no usable anchor/deals) — the never-block requirement + with an honest, priority-ordered source selection. + +`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F +relaxation cascade (room-adjacency / freshness / novostroyki / radius, see +estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in +Layer 1) ever runs the 3-way gate above — `listings` here is whatever that +cascade could find. Two layers: 1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) — - boundary behaviour of the gate itself. + boundary behaviour of the gate itself: the 3-way rule, low-reliability + wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_ + thin_listings bookkeeping. 2. `estimate_quality` integration tests — proves the money-path invariants - that matter to a caller: literal 0 never leaks as a "confident" price, - display `analogs` cards never outnumber what `n_analogs` claims, and the - explanation text describes what actually happened (not a stock "аналогов - не найдено" when some WERE found, just too few). + that matter to a caller: thin+usable-deals routes to the deals corridor + (Серов repro), thin+no-deals keeps its own median, display `analogs` + cards are shown either way, and the #oblast-F room-adjacency relaxation + (studio↔1-комн) actually grows a thin sample and is reported via + `AggregatedEstimate.relaxations` / `reliability`. """ from __future__ import annotations @@ -103,15 +136,14 @@ def test_threshold_is_five_not_lower() -> None: assert HEADLINE_LISTINGS_MIN_N == 5 -def test_four_listings_below_threshold_suppressed_no_fallback() -> None: - """n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state, - NOT the naive median of 4 listings.""" +def test_four_listings_below_threshold_kept_not_suppressed() -> None: + """#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision + 2026-08-10 — never zero out a thin-but-real sample), just flagged low.""" pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0])) - assert pr.median_ppm2 == 0.0 - assert pr.median_price == 0 - assert pr.n_analogs == 0 - assert pr.range_low == 0 - assert pr.range_high == 0 + assert pr.median_ppm2 == 215_000.0 + assert pr.n_analogs == 4 + assert pr.median_price == round(215_000.0 * 45.0) + assert pr.confidence == "low" def test_five_listings_at_threshold_not_suppressed() -> None: @@ -122,18 +154,21 @@ def test_five_listings_at_threshold_not_suppressed() -> None: assert pr.median_price == round(210_000.0 * 45.0) -def test_one_listing_below_threshold_suppressed() -> None: - """n=1 — the sharpest form of the Серов bug (a single random lot deciding - the whole headline) — must be suppressed exactly like n=4.""" +def test_one_listing_below_threshold_kept_not_suppressed() -> None: + """#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot) + median rather than being zeroed; confidence stays 'low'.""" pr = _call(listings=_lots([200_000.0])) - assert pr.median_ppm2 == 0.0 - assert pr.n_analogs == 0 + assert pr.median_ppm2 == 200_000.0 + assert pr.n_analogs == 1 + assert pr.confidence == "low" def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: - """n=3 listings (thin) + a usable ДКП corridor → headline comes from the - deal corridor median, NOT the 3-listing median (live Серов repro: 3 - listings gave 42 391 vs the honest ДКП-based ~54 126).""" + """#oblast-E priority RESTORED (2026-08-10 product correction): a thin + (n=3) listings sample must NOT outrank a usable ДКП deals corridor — this + is the exact live Серов repro #oblast-E exists for (3 noisy listings gave + 42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline + comes from the deal corridor median, NOT the 3-listing median.""" dkp_raw = { "count": 54, "low_ppm2": 44_000, @@ -151,12 +186,23 @@ def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: ) assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline" assert pr.confidence == "low" + assert pr.deals_headline_due_to_thin_listings is True + assert pr.listings_clean, "listings_clean must stay intact — display cards still show them" + # #4: explanation must not falsely claim "рядом нет объявлений" (some WERE + # found, just ceded priority to the more reliable deals corridor) and must + # NOT also carry the separate "Оценка построена по N аналогам" thin-kept + # wording (that phrasing is reserved for the no-usable-corridor branch). + assert pr.explanation is not None + assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "сделкам росреестра" in pr.explanation.lower() + assert "оценка построена по 3" not in pr.explanation.lower() -def test_thin_sample_with_insufficient_deals_stays_zero() -> None: +def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None: """n=3 listings (thin) + a ДКП corridor that is ITSELF too thin - (< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero, - not a fabricated number from either side.""" + (< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so + #oblast-F's never-block rule applies: the real listings median is kept + rather than refusing (neither source alone would justify a hard zero).""" dkp_raw = { "count": 1, "low_ppm2": 40_000, @@ -165,49 +211,51 @@ def test_thin_sample_with_insufficient_deals_stays_zero() -> None: "period_months": 12, } pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw) - assert pr.median_ppm2 == 0.0 - assert pr.median_price == 0 - assert pr.n_analogs == 0 + assert pr.median_ppm2 == 42_391.0 + assert pr.n_analogs == 3 + assert pr.deals_headline_due_to_thin_listings is False -def test_thin_sample_explanation_is_honest_about_count() -> None: - """The explanation for a thin-but-nonzero sample must say HOW MANY listings - were found (not the generic 'ничего не найдено' text used for a genuine - zero-listing case) — #4 in the task: explanation must match reality.""" +def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None: + """#4 (task spec): the explanation for a thin-but-real sample must read as + "small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для + оценки по рынку" copy, and NOT the generic zero-analogs text.""" pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2 assert pr.explanation is not None assert "2" in pr.explanation - assert "недостаточно" in pr.explanation.lower() - # Must NOT reuse the "nothing found at all" copy — 2 listings WERE found. + assert "выборка мала" in pr.explanation.lower() + assert "точность снижена" in pr.explanation.lower() + assert "минимум для оценки по рынку" not in pr.explanation.lower() assert "не найдено аналогов" not in pr.explanation.lower() -def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None: - """#4: once the ДКП fallback fires for a thin (not zero) sample, the - explanation must not falsely claim 'рядом нет объявлений' — some WERE - found, just not enough to trust.""" +def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None: + """Control: the #oblast-D deals-headline-fallback path is UNCHANGED for + GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin + case, not the true-zero case, which still needs a fallback source.""" dkp_raw = { - "count": 20, - "low_ppm2": 40_000, - "median_ppm2": 60_000, - "high_ppm2": 80_000, + "count": 54, + "low_ppm2": 44_000, + "median_ppm2": 65_957, + "high_ppm2": 89_000, "period_months": 12, } - pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw) + pr = _call(listings=[], dkp_raw=dkp_raw) + assert pr.median_ppm2 == 65_957.0 + assert pr.n_analogs == 0 + assert pr.confidence == "low" assert pr.explanation is not None - assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "рядом нет актуальных объявлений" in pr.explanation.lower() assert "сделкам росреестра" in pr.explanation.lower() -def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None: - """Regression guard: the gate must suppress the AGGREGATE (median/n_analogs) - without clearing `listings_clean` itself — the same-building anchor's own - ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell - "genuinely zero nearby listings" from "some nearby, just too few to trust - as headline", and conflating the two was caught regressing - test_estimator_split_corridor_1871.py during this change.""" +def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None: + """listings_clean stays intact (unchanged invariant — same-building anchor's + ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is + the REAL count (not zeroed) while listings_headline_thin_n still marks the + sample as thin for the low-reliability note upstream.""" pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0])) - assert pr.n_analogs == 0 + assert pr.n_analogs == 3 assert len(pr.listings_clean) == 3 assert pr.listings_headline_thin_n == 3 @@ -219,6 +267,42 @@ def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None: assert pr.listings_headline_thin_n == 0 +def test_repair_coefficient_now_applies_to_thin_sample() -> None: + """#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a + thin sample because the headline was already zeroed (applying it would be a + no-op). Now that the real median is kept, the coefficient must apply.""" + pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin + pr = _price_from_inputs( + listings=_lots([200_000.0, 210_000.0]), + area_m2=45.0, + rooms=2, + repair_state="excellent", + floor=5, + total_floors=9, + target_year=None, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=[], + anchor_tier_fetched=None, + dkp_raw=None, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=lambda _appm2: (None, None), + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + assert ( + pr.median_price != pr_no_repair.median_price + ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample" + + # ───────────────────────────────────────────────────────────────────────────── # Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) # ───────────────────────────────────────────────────────────────────────────── @@ -261,24 +345,31 @@ def _serov_payload() -> Any: def _run_estimate( *, - analogs: list[dict[str, Any]], + analogs: list[dict[str, Any]] | None = None, dkp_raw: dict[str, Any] | None, + fetch_analogs_side_effect: Any = None, + payload: Any = None, + geo: GeocodeResult | None = None, ) -> Any: from app.services.estimator import estimate_quality db = MagicMock() - payload = _serov_payload() + payload = payload or _serov_payload() + geo = geo or _geo() + + fetch_analogs_kwargs: dict[str, Any] = ( + {"side_effect": fetch_analogs_side_effect} + if fetch_analogs_side_effect is not None + else {"return_value": (list(analogs or []), False, "W")} + ) async def _run() -> Any: with ( - patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())), + 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, "W"), - ), + patch("app.services.estimator._fetch_analogs", **fetch_analogs_kwargs), patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), patch("app.services.estimator._fetch_deals", return_value=[]), patch( @@ -301,40 +392,47 @@ def _run_estimate( return anyio.run(_run) -def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None: - """Каменск-Уральский-style repro: thin listings, no usable ДКП corridor — - median_price_rub must be 0 AND insufficient_data must be True TOGETHER - (the AggregatedEstimate.insufficient_data computed_field invariant that - stops a literal 0 ₽ reaching the user as a confident number).""" +def test_e2e_thin_sample_no_relaxation_help_keeps_real_median() -> None: + """#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always + returns the SAME 2 listings regardless of relaxation params (none of them + help) — median_price_rub must be the REAL non-zero 2-listing median, + insufficient_data False, n_analogs=2, confidence='low', reliability + 'very_low' (n<3), relaxations empty (nothing actually helped).""" analogs = [ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), ] est = _run_estimate(analogs=analogs, dkp_raw=None) - assert est.median_price_rub == 0 - assert est.insufficient_data is True - assert est.n_analogs == 0 + assert est.median_price_rub == round(205_000.0 * 45.0) + assert est.insufficient_data is False + assert est.n_analogs == 2 assert est.confidence == "low" + assert est.relaxations == [] + assert est.reliability == "very_low" -def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None: - """The 2 thin listings must NOT be surfaced as `analogs` display cards while - n_analogs reports 0 — that would be the same dishonesty (confident-looking - UI) this whole gate exists to remove.""" +def test_e2e_thin_sample_display_cards_match_n_analogs() -> None: + """#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin + sample — they must match n_analogs exactly (both = 2), never hidden.""" analogs = [ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), ] est = _run_estimate(analogs=analogs, dkp_raw=None) - assert est.n_analogs == 0 - assert est.analogs == [] + assert est.n_analogs == 2 + assert len(est.analogs) == 2 def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: - """Live Серов repro (n=3 scraped listings, wide ДКП corridor available): - headline must come from the deal corridor, not the noisy 3-listing median, - and the estimate must be honestly non-'insufficient' (a real number, low - confidence, deals-sourced).""" + """Live Серов repro (n=3 scraped listings, wide ДКП corridor available) — + #oblast-E priority RESTORED: headline must come from the deal corridor, + not the noisy 3-listing median. Also proves the #4 task-spec requirements + layered on top of the restored priority: the estimate is honestly non- + 'insufficient' (a real number, low confidence), reliability is capped at + 'low' (not 'very_low' — a 54-deal corridor is real signal), the + relaxation label names the source switch, AND the 3 thin listings are + still shown as display cards (not discarded) even though they no longer + drive n_analogs/median.""" analogs = [ _make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"), _make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"), @@ -354,12 +452,15 @@ def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: assert est.confidence == "low" assert est.confidence_explanation is not None assert "сделкам росреестра" in est.confidence_explanation.lower() + assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'" + assert "оценка по сделкам — мало объявлений рядом" in est.relaxations + assert len(est.analogs) == 3, "thin listings must still surface as display cards" def test_e2e_sufficient_five_analogs_unaffected_control() -> None: """Control (mirrors the Екатеринбург prod check in the PR): a sample that clears the threshold is priced exactly as before — headline is the real - listings median, all 5 analogs counted.""" + listings median, all 5 analogs counted, no relaxations needed.""" analogs = [ _make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"), @@ -371,3 +472,78 @@ def test_e2e_sufficient_five_analogs_unaffected_control() -> None: assert est.median_price_per_m2 == 210_000 assert est.n_analogs == 5 assert est.insufficient_data is False + assert est.relaxations == [] + assert est.reliability == "low" # n=5 falls in the 3..7 bucket + + +def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None: + """#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin + sample (studio-adjacent building, live prod repro Академика Парина 46/5) → + cascade retries with rooms IN (0,1) and finds a trustworthy sample there. + Asserts: studios pulled in, `relaxations` names it, real non-zero median, + reliability downgraded to 'low' (thin base sample).""" + from app.schemas.trade_in import TradeInEstimateInput + + exact_rooms1 = [ + _make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0), + _make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0), + ] + studio_pool = [ + *exact_rooms1, + _make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0), + _make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0), + _make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0), + ] + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1: + return list(studio_pool), False, "W" + return list(exact_rooms1), False, "W" + + geo = GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5", + provider="nominatim", + ) + payload = TradeInEstimateInput( + address="ЕКБ, ул. Парина, 46/5", + area_m2=23.1, + rooms=1, + ) + + est = _run_estimate( + dkp_raw=None, + fetch_analogs_side_effect=_fetch_analogs_stub, + payload=payload, + geo=geo, + ) + + assert "учтены студии" in est.relaxations + assert est.median_price_rub > 0 + assert est.reliability == "low" + assert est.n_analogs == 5 + + +def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None: + """#oblast-F step (d) contract: when the user explicitly picked radius_m + (#2044), the cascade must NOT auto-expand past it — mirrors the existing + radius-fallback contract above (no auto-expansion beyond user's choice).""" + from app.schemas.trade_in import TradeInEstimateInput + + thin = [ + _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), + _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), + ] + payload = TradeInEstimateInput( + address="Серов, ул. Ленина, 5", + area_m2=45.0, + rooms=2, + floor=5, + total_floors=9, + city_hint="Серов", + radius_m=1500, + ) + est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload) + assert not any("радиус расширен" in r for r in est.relaxations) + assert est.search_radius_m == 1500 diff --git a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py index 0803ab8f..b5d6ebbe 100644 --- a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py +++ b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py @@ -24,6 +24,14 @@ from app.services.estimator import ( from app.services.scraper_settings import get_scraper_delay +def _history_rows(db) -> list[dict]: + """Строки батча house_placement_history из мока сессии (фильтр по SQL, не по позиции).""" + for call in db.execute.call_args_list: + if "INSERT INTO house_placement_history" in str(call.args[0]): + return call.args[1] + return [] + + def _sample_result(address: str = "Екатеринбург, ул. Учителей, 18") -> YandexValuationResult: return YandexValuationResult( address=address, @@ -170,15 +178,22 @@ def test_save_history_items_inserts_each(): ): saved = _save_yandex_history_items(db, result) assert saved == 2 - # 1 batch INSERT (executemany) + 1 commit - assert db.execute.call_count == 1 - args = db.execute.call_args - rows = args.args[1] + # 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE + # houses.has_panorama — считаем именно вставки истории, а не все execute. + rows = _history_rows(db) assert isinstance(rows, list) and len(rows) == 2 - db.commit.assert_called_once() + # Два коммита: панорама (до истории) + батч истории. Раньше был один. + assert db.commit.call_count == 2 def test_save_history_items_empty_no_commit(): + """Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего. + + #2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно + отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%). + Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается — + а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает. + """ db = MagicMock() result = YandexValuationResult( address="x", @@ -189,11 +204,13 @@ def test_save_history_items_empty_no_commit(): house=ValuationHouseMeta(), history_items=[], ) - # match_or_create_house must NOT be called when there are no items (early return) - with patch("app.services.estimator.match_or_create_house") as m: + with patch( + "app.services.estimator.match_or_create_house", + return_value=(1, 0.9, "fingerprint"), + ) as m: saved = _save_yandex_history_items(db, result) assert saved == 0 - m.assert_not_called() + m.assert_called_once() db.execute.assert_not_called() db.commit.assert_not_called() @@ -227,9 +244,20 @@ 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).""" + """Any item failing rolls back the whole batch — batch semantics (finding #5). + + #2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение + доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает), + а батч истории проходил бы успешно, и тест молча проверял бы не тот путь. + """ db = MagicMock() - db.execute.side_effect = [RuntimeError("first row fails"), None] + + def _fail_history(sql, *args, **kwargs): + if "INSERT INTO house_placement_history" in str(sql): + raise RuntimeError("first row fails") + return MagicMock() + + db.execute.side_effect = _fail_history result = _sample_result() with patch( "app.services.estimator.match_or_create_house", @@ -238,4 +266,5 @@ 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() - db.commit.assert_not_called() + # Панорама коммитится отдельно и раньше — её успех не отменяет отката истории. + assert db.commit.call_count == 1 diff --git a/tradein-mvp/backend/tests/test_freshness_filter_2656.py b/tradein-mvp/backend/tests/test_freshness_filter_2656.py index 6a884e9c..3d50216a 100644 --- a/tradein-mvp/backend/tests/test_freshness_filter_2656.py +++ b/tradein-mvp/backend/tests/test_freshness_filter_2656.py @@ -323,14 +323,20 @@ def test_imv_card_survives_when_headline_suppressed_and_anchor_absent() -> None: Раньше её заполнял display-only блок под условием `anchor_tier is not None`. После сброса залипшего флага открылась щель: blend не срабатывает (нужен median_price > 0), старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ карточку, которую видел - раньше. n=3 объявления (< HEADLINE_LISTINGS_MIN_N) → headline подавлен, median 0. - Отображение, не деньги: median/expected_sold блок не трогает. + раньше. Отображение, не деньги: median/expected_sold блок не трогает. + + Нулевой headline берём отсутствием аналогов (n=0). Раньше здесь стояло n=3 — + тонкая выборка ниже HEADLINE_LISTINGS_MIN_N, которую гейт достаточности обнулял. + #oblast-F (#2823, смержен 2026-08-09) это поведение снял: тонкая выборка больше + НЕ обнуляет headline, только помечает низкую надёжность. Щель, которую стережёт + тест, от этого не исчезла — она просто достигается единственным оставшимся + нулевым состоянием. """ pr = _price( anchor_comps=[_anchor_comp(150_000.0), _anchor_comp(155_000.0)], anchor_tier_fetched="C", imv_anchor={"recommended_price": 14_000_000}, - n_listings=3, + n_listings=0, ) assert pr.anchor_tier is None diff --git a/tradein-mvp/backend/tests/test_gar_flats_loader.py b/tradein-mvp/backend/tests/test_gar_flats_loader.py index e98fc4c5..62b2e9a3 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -449,8 +449,14 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: db = _live_session() assert db is not None try: - db.execute(text(_NORMALIZER_FN)) - db.execute(text(_CANON_FN)) + # exec_driver_sql, НЕ text(): в обеих функциях есть regexp-группы `(?:...)`, + # а `text()` читает `:Россия` / `:кв` / `:асть` как bind-параметры и падает + # с InvalidRequestError ещё до первого утверждения. Драйверный путь ничего + # не парсит. (Тест жил с 2026-06-28 и до #2741 не исполнялся ни разу — под + # skipif «нет БД» падение было не видно.) + raw = db.connection() + raw.exec_driver_sql(_NORMALIZER_FN) + raw.exec_driver_sql(_CANON_FN) db.execute( text( "CREATE TEMP TABLE gar_house_flats (" diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index c6bacef4..4355e323 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -18,6 +18,7 @@ import inspect import os import re from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -106,6 +107,24 @@ def test_keeper_rule_priority_order() -> None: assert col in hdm._COMPLETENESS_EXPR +def test_keeper_listing_count_puts_nulls_last() -> None: + """«Больше объявлений» обязано означать больше, а не меньше (#2674). + + listing_cnt приходит из LEFT JOIN listing_counts → у дома БЕЗ объявлений он NULL, а `DESC` + в Postgres по умолчанию NULLS FIRST. Без явного NULLS LAST правило инвертировано: пустая + запись обгоняет запись со 192 объявлениями и забирает роль keeper'а, после чего объявления + проигравшего переезжают на дом, на который корпус никогда не ссылался, а неполный + COALESCE-перенос (год постройки / тип / этажность / застройщик не переносятся) удаляет + данные более богатой строки. RED до фикса: был голый `listing_cnt DESC`. + """ + order = _flat(hdm._KEEPER_ORDER) + assert "listing_cnt DESC NULLS LAST" in order + # Оба места, где применяется порядок (ROW_NUMBER-ранг и first_value-выбор keeper'а), + # берут одну и ту же константу — иначе ранг и keeper разъедутся построчно. + mapping = _flat(hdm._mapping_sql(hdm._FIAS_KEY_EXPR, apply_geo_guard=False)) + assert mapping.count("listing_cnt DESC NULLS LAST") >= 2 + + def test_mapping_selects_losers_only() -> None: """The mapping is loser_id→keeper_id; the keeper itself is excluded (rn>1, id<>keeper).""" flat = _flat(_MAPPING_SQL) @@ -266,9 +285,18 @@ def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None: assert "keeper_geom IS NOT NULL" in canon assert "loser_geom IS NOT NULL" in canon # fias pass drops the distance guard AND the NULL-geom exclusions entirely. - assert "ST_DistanceSphere" not in fias - assert "loser_geom IS NOT NULL" not in fias - assert "keeper_geom IS NOT NULL" not in fias + # + # Asserted on the guard PREDICATE, not on the bare function name: since #2690 the mapping also + # MEASURES the keeper↔loser distance into `distance_m` for the merge journal, on both passes. + # Measuring is the opposite of guarding — the fias pass is precisely where nothing else records + # how far apart the merged rows were — so the name alone can no longer stand in for the guard. + assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias + guard = ( + "AND keeper_geom IS NOT NULL AND loser_geom IS NOT NULL " + "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" + ) + assert guard in canon + assert guard not in fias # the cross-fias anti-over-merge guard is untouched in the canon pass. assert "lower(loser_fias) <> lower(keeper_fias)" in canon @@ -276,12 +304,15 @@ def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None: def test_mapping_sql_geo_guard_param_toggles_only_distance_filter() -> None: """_mapping_sql(apply_geo_guard=...) toggles ONLY the 250 m distance filter; the cross-fias guard is emitted regardless, and the default is True (canon-safe).""" + guard = "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" with_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=True)) without_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=False)) - assert "ST_DistanceSphere" in with_guard - assert "ST_DistanceSphere" not in without_guard + assert guard in with_guard + assert guard not in without_guard # default = True (the canon pass must never lose its guard by omission). - assert "ST_DistanceSphere" in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + assert guard in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + # ...while the journal's distance MEASUREMENT is emitted either way (#2690). + assert "AS distance_m" in with_guard and "AS distance_m" in without_guard # cross-fias guard present in BOTH renderings (independent of the geo guard). assert "lower(loser_fias) <> lower(keeper_fias)" in with_guard assert "lower(loser_fias) <> lower(keeper_fias)" in without_guard @@ -309,7 +340,9 @@ def test_both_passes_share_one_pipeline_no_copy_paste() -> None: assert token in canon and token in fias # the 250 m distance guard is CANON-ONLY (#2187) — fias identity outranks proximity. assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" in canon - assert "ST_DistanceSphere" not in fias + assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias + # ...but the journal's distance MEASUREMENT is on both — measuring is not guarding. + assert "AS distance_m" in canon and "AS distance_m" in fias def test_cross_fias_guard_blocks_slash_collapse_over_merge() -> None: @@ -415,28 +448,72 @@ class _FakeResult: class _Row: - def __init__(self, loser_id: int, keeper_id: int, norm_address: str): + def __init__( + self, + loser_id: int, + keeper_id: int, + norm_address: str, + cluster_key: str = "addr:тест", + distance_m: float | None = 12.0, + ): self.loser_id = loser_id self.keeper_id = keeper_id self.norm_address = norm_address + # journal grounds (#2690): which key value fired, and how far apart the rows were. + self.cluster_key = cluster_key + self.distance_m = distance_m + + +class _ChildRow: + """What a step's RETURNING yields: an id for a survivor, a snapshot for a destroyed row.""" + + def __init__(self, loser_id: int, child_id: int = 1): + self.loser_id = loser_id + self.child_id = child_id + self.row_snapshot = {"id": child_id, "house_id": loser_id} + + +class _FKChild: + def __init__(self, child_table: str, fk_column: str): + self.child_table = child_table + self.fk_column = fk_column class _FakeDB: """Session stand-in: build-mapping + a scripted SELECT result, then per-step rowcounts.""" - def __init__(self, mapping_rows: list[_Row], step_rowcount: int = 1): + def __init__( + self, + mapping_rows: list[_Row], + step_rowcount: int = 1, + fk_children: dict[str, str] | None = None, + residual: dict[str, int] | None = None, + residual_raises: bool = False, + ): self._mapping_rows = mapping_rows self._step_rowcount = step_rowcount + self._residual = residual + self._residual_raises = residual_raises self._mapping_served = False + # The catalog the FK-child guard reads; defaults to the real live set. + self._fk_children = _FK_CHILDREN if fk_children is None else fk_children self.commits = 0 self.rollbacks = 0 self.executed: list[str] = [] - def execute(self, clause: Any, params: dict | None = None) -> _FakeResult: + def execute(self, clause: Any, params: Any = None) -> _FakeResult: sql = str(getattr(clause, "text", clause)) self.executed.append(sql) if "CREATE TEMP TABLE" in sql: return _FakeResult() + if "FROM pg_constraint" in sql: + return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()]) + if "AS residual_rows" in sql: # residual census (#2690) — read-only, after both passes + if self._residual_raises: + raise RuntimeError("census exploded") + if self._residual is None: + return _FakeResult(rows=[]) + return _FakeResult(rows=[SimpleNamespace(**self._residual)]) if "SELECT loser_id, keeper_id, norm_address" in sql: # The service now runs TWO passes (fias, then canon). Model «fias pass found the # duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards. @@ -444,7 +521,13 @@ class _FakeDB: return _FakeResult(rows=[]) self._mapping_served = True return _FakeResult(rows=list(self._mapping_rows)) - # any UPDATE/DELETE/INSERT step (incl. DROP TABLE, carry-over, delete, backfill) + # Steps now RETURN the rows they touched (journal, #2690) — one per scripted rowcount, + # attributed to the first loser so the per-loser bookkeeping has something to bucket. + if "RETURNING" in sql: + loser = self._mapping_rows[0].loser_id if self._mapping_rows else 0 + rows = [_ChildRow(loser, child_id=i + 1) for i in range(self._step_rowcount)] + return _FakeResult(rowcount=self._step_rowcount, rows=rows) + # any other UPDATE/DELETE/INSERT (DROP TABLE, journal, carry-over, delete, backfill) return _FakeResult(rowcount=self._step_rowcount) def commit(self) -> None: @@ -478,6 +561,85 @@ def test_dry_run_computes_counts_but_rolls_back() -> None: assert db.rollbacks == 1 +# ── Residual census (#2690 п.2/п.4): остаток фиксируется числом, а не оценкой ── + + +def test_residual_census_reuses_the_canon_mapping_prelude() -> None: + """Перепись остатка обязана считать РОВНО те строки, о которых рассуждает канон-проход. + + Общий `_ranked_cte` — не косметика: собственная копия кластеризации разъехалась бы с + проходом, который она описывает, и разъезд был бы невидим (это тот же класс ошибки, что и + ключ в #2690 — два выражения, похожие друг на друга и не равные). RED до фикса: переписи + нет вовсе. + """ + residual = _flat(str(hdm._RESIDUAL_SQL.text)) + prelude = _flat(hdm._ranked_cte(hdm._CANON_KEY_EXPR)) + assert prelude in residual + assert prelude in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + + +def test_residual_census_keeps_silent_guard_apart_from_rejecting_guard() -> None: + """«Координат нет» и «дальше 250 м» — противоположные факты, в одну сумму их нельзя. + + Первое означает, что страж не смог высказаться (остаток неизвестен), второе — что он + высказался по существу и дома РАЗНЫЕ (это вообще не дубли). Сумма из этих двух читается как + «долг», которого нет. Плюс: перепись НЕ применяет гео-фильтр — иначе она считала бы только + то, что и так слилось. + """ + residual = _flat(str(hdm._RESIDUAL_SQL.text)) + for bucket in ( + "AS residual_rows", + "AS residual_listings", + "AS residual_no_geom", + "AS residual_far", + "AS residual_cross_fias", + "AS residual_mergeable", + ): + assert bucket in residual, bucket + # Гео-страж 250 м здесь — РАЗДЕЛИТЕЛЬ корзин, а не фильтр строк. + assert "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in residual + assert set(hdm.DedupMergeResult().to_counters()) >= { + "residual_rows", + "residual_listings", + "residual_no_geom", + "residual_far", + "residual_cross_fias", + "residual_mergeable", + } + + +def test_residual_counters_reported_even_when_nothing_merged() -> None: + """Прогон, который не слил ничего, — ровно тот, чьему остатку нужно число.""" + db = _FakeDB( + mapping_rows=[], + residual={ + "residual_rows": 963, + "residual_listings": 1765, + "residual_no_geom": 326, + "residual_far": 568, + "residual_cross_fias": 8, + "residual_mergeable": 61, + }, + ) + out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert out["losers_deleted"] == 0 + assert out["residual_rows"] == 963 + assert out["residual_listings"] == 1765 + assert out["residual_no_geom"] == 326 + assert out["residual_far"] == 568 + assert out["residual_cross_fias"] == 8 + assert out["residual_mergeable"] == 61 + + +def test_residual_census_failure_never_breaks_the_merge() -> None: + """Перепись — приборы, слияние — продукт: упавший счётчик не отменяет коммит.""" + db = _FakeDB(mapping_rows=[_Row(2, 1, "ул. мира, 10")], residual_raises=True) + out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert out["losers_deleted"] == 1 + assert out["residual_rows"] == 0 # неизвестно — и это видно, а не выдумано + assert db.commits == 1 + + def test_real_merge_commits() -> None: """dry_run=False with dups → exactly one commit, no rollback.""" rows = [_Row(2, 1, "ул. мира, 10")] @@ -506,7 +668,11 @@ def test_run_wrapper_marks_done_with_counters(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr( hdm, "merge_duplicate_houses", - lambda _db, dry_run=False: {"clusters_merged": 3, "losers_deleted": 5, "dry_run": 0}, + lambda _db, dry_run=False, run_id=None, initiator="manual": { + "clusters_merged": 3, + "losers_deleted": 5, + "dry_run": 0, + }, ) out = hdm.run_house_dedup_merge(object(), run_id=42, params={"dry_run": False}) # type: ignore[arg-type] @@ -523,13 +689,20 @@ def test_run_wrapper_passes_dry_run_param(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setattr(runs_mod, "mark_done", lambda *a, **k: None) monkeypatch.setattr(runs_mod, "mark_failed", lambda *a, **k: None) - def _fake_merge(_db: Any, dry_run: bool = False) -> dict[str, int]: + def _fake_merge( + _db: Any, dry_run: bool = False, run_id: int | None = None, initiator: str = "manual" + ) -> dict[str, int]: captured["dry_run"] = dry_run + captured["run_id"] = run_id + captured["initiator"] = initiator return {"dry_run": int(dry_run)} monkeypatch.setattr(hdm, "merge_duplicate_houses", _fake_merge) hdm.run_house_dedup_merge(object(), run_id=1, params={"dry_run": True}) # type: ignore[arg-type] assert captured["dry_run"] is True + # the journal must be able to say WHICH run did it, and that it was not a human (#2690) + assert captured["run_id"] == 1 + assert captured["initiator"] == "schedule" def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> None: @@ -544,7 +717,9 @@ def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> N lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err), ) - def _boom(_db: Any, dry_run: bool = False) -> dict[str, int]: + def _boom( + _db: Any, dry_run: bool = False, run_id: int | None = None, initiator: str = "manual" + ) -> dict[str, int]: raise RuntimeError("merge exploded") monkeypatch.setattr(hdm, "merge_duplicate_houses", _boom) @@ -624,12 +799,16 @@ def test_real_merge_repoints_dedups_deletes_and_is_idempotent() -> None: db = _live_session() assert db is not None try: - # Two houses at the SAME address. Keeper (geom present) should win. + # Two houses at the SAME address, ~10 m apart (the #2187 canon geo guard needs geom + # on BOTH sides). Keeper = min(id) once geom and listing counts tie. db.execute( _t( - "INSERT INTO houses (id, source, ext_house_id, address, lat, lon) VALUES " - "(900001, 'avito', 'EXT-KEEP', 'тестдом 1772, 1', 56.84, 60.60)," - "(900002, 'cian', 'EXT-LOSE', 'тестдом 1772, 1', NULL, NULL)" + # url is NOT NULL in houses (002_core_tables); nothing here asserts on it, + # so 'u' is a placeholder. These live-DB fixtures self-skip in CI, which is + # how they silently drifted out of sync with the schema in the first place. + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES " + "(900001, 'avito', 'EXT-KEEP','u', 'тестдом 1772, 1', 56.84, 60.60)," + "(900002, 'cian', 'EXT-LOSE','u', 'тестдом 1772, 1', 56.84009, 60.60)" ) ) # listings pointing at BOTH (the loser's must be re-pointed). source_url, dedup_hash, @@ -737,6 +916,9 @@ def test_real_merge_repoints_dedups_deletes_and_is_idempotent() -> None: db.execute( _t("DELETE FROM house_address_aliases WHERE normalized_address = 'тестдом 1772, 1'") ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id IN (900001,900002)")) db.commit() db.close() @@ -763,16 +945,16 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: try: db.execute( _t( - "INSERT INTO houses (id, source, ext_house_id, address, lat, lon) VALUES " + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES " # A — ул/улица spelling variants of the SAME building, ~10 m apart → MERGE - "(900010, 'avito', 'EXT-T-VK', 'улица Тестовая1772, 66', 56.84000, 60.60000)," - "(900011, 'cian', 'EXT-T-VL', 'ул. Тестовая1772, 66', 56.84009, 60.60000)," + "(900010, 'avito', 'EXT-T-VK','u', 'улица Тестовая1772, 66', 56.84000, 60.60000)," + "(900011, 'cian', 'EXT-T-VL','u', 'ул. Тестовая1772, 66', 56.84009, 60.60000)," # B — same canon (ленина-like) but ~5 km apart → geo guard BLOCKS the merge - "(900012, 'avito', 'EXT-T-L1', 'улица Тестовая1772, 5', 56.84000, 60.60000)," - "(900013, 'cian', 'EXT-T-L2', 'улица Тестовая1772, 5', 56.88500, 60.60000)," + "(900012, 'avito', 'EXT-T-L1','u', 'улица Тестовая1772, 5', 56.84000, 60.60000)," + "(900013, 'cian', 'EXT-T-L2','u', 'улица Тестовая1772, 5', 56.88500, 60.60000)," # C — different корпус → different canon, ~10 m apart → NOT merged - "(900014, 'avito', 'EXT-T-M2', 'Тестовая1772, 34к2', 56.84000, 60.60000)," - "(900015, 'cian', 'EXT-T-M4', 'Тестовая1772, 34к4', 56.84009, 60.60000)" + "(900014, 'avito', 'EXT-T-M2','u', 'Тестовая1772, 34к2', 56.84000, 60.60000)," + "(900015, 'cian', 'EXT-T-M4','u', 'Тестовая1772, 34к4', 56.84009, 60.60000)" ) ) db.execute( @@ -822,7 +1004,7 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: db.execute( _t( "DELETE FROM house_sources WHERE ext_id IN " - "('EXT-T-VK','EXT-T-VL','EXT-T-L1','EXT-T-L2','EXT-T-M2','EXT-T-M4')" + "('EXT-T-VK','u','EXT-T-VL','u','EXT-T-L1','u','EXT-T-L2','u','EXT-T-M2','u','EXT-T-M4')" ) ) db.execute( @@ -832,6 +1014,9 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: "'тестовая1772, 34к2','тестовая1772, 34к4')" ) ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900010 AND 900015")) db.commit() db.close() @@ -859,21 +1044,21 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: db.execute( _t( "INSERT INTO houses " - "(id, source, ext_house_id, address, lat, lon, house_fias_id, gar_house_guid, " + "(id, source, ext_house_id, url, address, lat, lon, house_fias_id, gar_house_guid, " " dadata_enriched_at) VALUES " # A — same fias, different canon (different streets), ~10 m apart → FIAS-pass merge - "(900020,'avito','EXT-F-K','ФиасОдин1772, 10', 56.84000,60.60000," + "(900020,'avito','EXT-F-K','u','ФиасОдин1772, 10', 56.84000,60.60000," " 'F-SAME-1772',NULL,NULL)," - "(900021,'cian', 'EXT-F-L','СовсемДругая1772, 77',56.84009,60.60000," + "(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000," " 'F-SAME-1772',NULL,NULL)," # B — same canon (slash-collapse), DIFFERENT fias → cross-fias guard BLOCKS - "(900022,'avito','EXT-B-1','Клара1772, 32',56.84000,60.60000," + "(900022,'avito','EXT-B-1','u','Клара1772, 32',56.84000,60.60000," " 'F-B1-1772',NULL,NULL)," - "(900023,'cian', 'EXT-B-2','Клара1772, 3/2',56.84009,60.60000," + "(900023,'cian', 'EXT-B-2','u','Клара1772, 3/2',56.84009,60.60000," " 'F-B2-1772',NULL,NULL)," # C — same canon, fias only on the loser → canon-pass merge + carry-over - "(900024,'avito','EXT-C-K','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL)," - "(900025,'cian', 'EXT-C-L','Донбасс1772, 8',56.84009,60.60000," + "(900024,'avito','EXT-C-K','u','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL)," + "(900025,'cian', 'EXT-C-L','u','Донбасс1772, 8',56.84009,60.60000," " 'F-CARRY-1772','G-CARRY-1772',NOW())" ) ) @@ -926,7 +1111,7 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: db.execute( _t( "DELETE FROM house_sources WHERE ext_id IN " - "('EXT-F-K','EXT-F-L','EXT-B-1','EXT-B-2','EXT-C-K','EXT-C-L')" + "('EXT-F-K','u','EXT-F-L','u','EXT-B-1','u','EXT-B-2','u','EXT-C-K','u','EXT-C-L')" ) ) db.execute( @@ -936,6 +1121,9 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: "'донбасс1772, 8')" ) ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900020 AND 900025")) db.commit() db.close() @@ -964,16 +1152,16 @@ def test_real_fias_pass_ignores_geo_guard() -> None: db.execute( _t( "INSERT INTO houses " - "(id, source, ext_house_id, address, lat, lon, house_fias_id) VALUES " + "(id, source, ext_house_id, url, address, lat, lon, house_fias_id) VALUES " # A — same fias, loser NULL geom → fias pass merges despite the missing coordinate - "(900030,'avito','EXT-2187-A-K','ФиасГеоA2187, 1', 56.84000,60.60000,'F-A-2187')," - "(900031,'cian', 'EXT-2187-A-L','ФиасГеоAL2187, 2',NULL, NULL, 'F-A-2187')," + "(900030,'avito','EXT-2187-A-K','u','ФиасГеоA2187, 1',56.84,60.6,'F-A-2187')," + "(900031,'cian', 'EXT-2187-A-L','u','ФиасГеоAL2187, 2',NULL,NULL,'F-A-2187')," # B — same fias, ~5 km apart (>250 m) → fias pass merges despite the distance - "(900032,'avito','EXT-2187-B-K','ФиасГеоB2187, 3', 56.84000,60.60000,'F-B-2187')," - "(900033,'cian', 'EXT-2187-B-L','ФиасГеоBL2187, 4',56.88500,60.60000,'F-B-2187')," + "(900032,'avito','EXT-2187-B-K','u','ФиасГеоB2187, 3',56.84,60.6,'F-B-2187')," + "(900033,'cian', 'EXT-2187-B-L','u','ФиасГеоBL2187, 4',56.885,60.6,'F-B-2187')," # C — same canon, NO fias, ~5 km apart → canon pass STILL blocks (guard unchanged) - "(900034,'avito','EXT-2187-C-1','КанонГео2187, 5', 56.84000,60.60000,NULL)," - "(900035,'cian', 'EXT-2187-C-2','КанонГео2187, 5', 56.88500,60.60000,NULL)" + "(900034,'avito','EXT-2187-C-1','u','КанонГео2187, 5', 56.84000,60.60000,NULL)," + "(900035,'cian', 'EXT-2187-C-2','u','КанонГео2187, 5', 56.88500,60.60000,NULL)" ) ) # A loser gets a listing so we prove the re-point still fires with a NULL-geom loser. @@ -1010,6 +1198,199 @@ def test_real_fias_pass_ignores_geo_guard() -> None: db.execute(_t("DELETE FROM listings WHERE id = 910031")) db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900030 AND 900035")) db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900030 AND 900035")) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900030 AND 900035")) db.commit() db.close() + + +# ── Merge journal: reversibility (#2690) ────────────────────────────────────── + + +def test_undo_carryover_fields_match_merge_carryover() -> None: + """Static drift guard: migration 230's undo must un-set EXACTLY the fields the merge carries. + + The undo NULLs the keeper's identity fields that the merge COALESCE-filled from a loser. + If _CARRY_OVER_IDENTITY_SQL ever gains a field and the migration's array does not, the undo + silently leaves that field on the keeper — the restored loser and the keeper would then both + claim the same ФИАС, and the next fias pass would merge them straight back. + """ + migration = (_SQL_DIR / "230_house_merge_log.sql").read_text(encoding="utf-8") + carried = set(re.findall(r"^\s+(\w+)\s*=\s*COALESCE\(k\.", _CARRY_SQL, re.M)) + # slice the ARRAY[...] literal itself — the declaration's own `text[]` also holds a «]» + block = migration[migration.index("c_carry_fields") :] + undone = set(re.findall(r"'(\w+)'", block[block.index("ARRAY[") : block.index("];")])) + assert carried, "could not parse carried fields out of _CARRY_OVER_IDENTITY_SQL" + assert carried == undone, f"carry-over/undo field drift: merge={carried} undo={undone}" + + +def test_journal_written_in_the_same_transaction_as_the_merge() -> None: + """The journal INSERT must sit between mapping and delete, with no commit in between. + + Requirement from #2690: a merge that commits without its journal row is exactly the failure + the journal exists to prevent, so the two must share one transaction. + """ + src = inspect.getsource(hdm._run_merge_pass) + assert "_JOURNAL_INSERT_SQL" in src + assert "db.commit()" not in src, "the pass must not commit — the caller owns the txn" + # phase 1 (snapshots) strictly before the steps mutate anything, delete strictly after. + assert src.index("_JOURNAL_INSERT_SQL") < src.index("for label, sql in _STEPS") + assert src.index("for label, sql in _STEPS") < src.index("_DELETE_LOSERS_SQL") + + +def test_every_step_returns_what_it_touched() -> None: + """Each step must RETURN its rows: ids for survivors, full snapshots for destroyed rows.""" + for label, sql in hdm._STEPS: + assert "RETURNING" in sql, f"{label}: no RETURNING — its rows would go unjournaled" + if label.endswith("(collision-delete)") or label.endswith("(dedup)"): + assert "to_jsonb(" in sql, f"{label}: destroys rows, must snapshot them, not ids" + else: + assert "AS child_id" in sql, f"{label}: re-points rows, must return their ids" + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_merge_is_reversible_via_journal() -> None: + """End-to-end on a real DB: merge → journal is sufficient → undo restores the ORIGINAL state. + + The comparison is over `to_jsonb(row.*)` for every row that existed before the merge — all + columns, not a chosen pair — for houses and for every FK child touched. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + ids = "(900201, 900202)" + try: + # Keeper 900201 and loser 900202: same canon address, ~12 m apart (inside the 250 m + # guard), keeper has the listings so the keeper rule picks it. + db.execute( + _t( + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, geom, " + "year_built, house_fias_id, gar_flat_count, raw_payload) VALUES " + "(900201,'avito','K-2690','http://t/2690/k','улица Журнальная, 7', " + " 56.8400, 60.6000, ST_SetSRID(ST_MakePoint(60.6000,56.8400),4326), " + " 1979, NULL, NULL, '{\"k\":[1,2]}'), " + "(900202,'cian','L-2690','http://t/2690/l','ул. Журнальная,7', " + " 56.8401, 60.6000, ST_SetSRID(ST_MakePoint(60.6000,56.8401),4326), " + # NB: no «:word» inside the literal — SQLAlchemy text() would read it as a bind. + " NULL, 'fias-2690-uuid', 144, '{\"l\":{\"deep\":[3,4]}}')" + ) + ) + db.execute( + _t( + "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, " + "house_id_fk) VALUES " + "(910201,'avito','http://t/2690/1','L1','dh-2690-1',5000000,900201)," + "(910202,'avito','http://t/2690/2','L2','dh-2690-2',5100000,900201)," + "(910203,'cian','http://t/2690/3','L3','dh-2690-3',6000000,900202)" + ) + ) + db.execute( + _t( + "INSERT INTO house_sources (house_id, ext_source, ext_id, confidence, " + "matched_method) VALUES (900201,'avito','S-2690-K',1.0,'t')," + "(900202,'cian','S-2690-L',1.0,'t')" + ) + ) + # Colliding child: identical 6-col UNIQUE key on both → the loser's row is DESTROYED by + # the dedup step. Only a full snapshot can bring it back. + db.execute( + _t( + "INSERT INTO houses_price_dynamics (house_id, month_date, source, room_count, " + "prices_type, period, price_per_sqm) VALUES " + "(900201, DATE '2026-02-01','cian','all','priceSqm','allTime',100000)," + "(900202, DATE '2026-02-01','cian','all','priceSqm','allTime',999999)" + ) + ) + db.commit() + + def snapshot() -> dict[tuple[str, int], Any]: + """to_jsonb of every seeded row, keyed by (table, id) — the full-fidelity state.""" + out: dict[tuple[str, int], Any] = {} + for tbl, col in ( + ("houses", "id"), + ("listings", "house_id_fk"), + ("house_sources", "house_id"), + ("houses_price_dynamics", "house_id"), + ): + where = f"id IN {ids}" if tbl == "houses" else f"{col} IN {ids}" + for r in db.execute( + _t(f"SELECT id, to_jsonb(t.*) AS j FROM {tbl} t WHERE {where}") + ): + out[(tbl, r.id)] = r.j + return out + + before = snapshot() + assert len(before) == 9, f"fixture should seed 9 rows, got {sorted(before)}" + + # ── merge ── + out = hdm.merge_duplicate_houses(db, dry_run=False, initiator="test") + assert out["losers_deleted"] == 1 + assert db.execute(_t(f"SELECT count(*) FROM houses WHERE id IN {ids}")).scalar() == 1 + + # ── the journal alone must be able to NAME what went into what ── + row = db.execute( + _t("SELECT * FROM house_merge_log WHERE loser_id = 900202 ORDER BY id DESC LIMIT 1") + ).one() + assert (row.loser_id, row.keeper_id) == (900202, 900201) + assert row.merge_pass == "canon" and row.geo_guard is True + assert row.cluster_key.startswith("addr:") + assert 0 < row.distance_m < 250, "distance to the keeper must be recorded, in metres" + assert row.initiator == "test" + # full snapshot of the deleted row, not a reference to it + assert row.loser_row == before[("houses", 900202)] + # keeper as it stood BEFORE the identity carry-over (fias still empty there, filled now) + assert row.keeper_before["house_fias_id"] is None + assert ( + db.execute(_t("SELECT house_fias_id FROM houses WHERE id = 900201")).scalar() + == "fias-2690-uuid" + ), "carry-over should have moved the loser's fias up" + # children: the loser's listing moved by id, the destroyed price row by content + assert row.children_repointed["listings.house_id_fk"] == [910203] + assert [r["price_per_sqm"] for r in row.children_deleted["houses_price_dynamics"]] == [ + 999999 + ] + + # ── undo ── + report = db.execute( + _t("SELECT * FROM house_merge_undo(CAST(:b AS uuid))"), {"b": str(row.batch_id)} + ).all() + assert len(report) == 1 and report[0].out_status.startswith("restored:"), report + db.commit() + + after = snapshot() + # every row that existed before is back, byte-identical, on every column + assert {k: v for k, v in after.items() if k in before} == before + # the ONLY residue is the house_sources row the merge backfilled for the keeper. + # migration 230 documents this: it is built from the keeper's OWN ext_house_id, so + # it would have been true without the merge too. Asserted, not assumed. + residue = [v for k, v in after.items() if k not in before] + assert all(v["matched_method"] == "backfill_dedup_merge" for v in residue), residue + finally: + db.rollback() + db.execute(_t(f"DELETE FROM listings WHERE house_id_fk IN {ids}")) + db.execute(_t("DELETE FROM listings WHERE id IN (910201,910202,910203)")) + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id = 900202")) + db.execute(_t("DELETE FROM house_address_aliases WHERE house_id IN (900201,900202)")) + db.execute(_t(f"DELETE FROM houses WHERE id IN {ids}")) + db.commit() + db.close() + + +def test_merge_refuses_when_an_fk_child_is_unhandled() -> None: + """A new FK child on houses(id) must ABORT the merge, not be CASCADE-deleted unjournaled. + + 9 of the 11 FKs are ON DELETE CASCADE. A child the _STEPS pipeline does not know about is + therefore destroyed by `DELETE FROM houses` — no re-point step touches it, no RETURNING + records it, and the journal would claim a complete snapshot it does not have. Migration 133 + already broke on prod over a missed child; there it failed loudly, here it would be silent. + """ + db = _FakeDB( + mapping_rows=[_Row(2, 1, "ул. ленина, 5")], + fk_children={**_FK_CHILDREN, "house_brand_new_child": "house_id"}, + ) + with pytest.raises(RuntimeError, match="house_brand_new_child"): + hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert db.commits == 0, "an unhandled child must abort before anything is committed" diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py new file mode 100644 index 00000000..c36aa891 --- /dev/null +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -0,0 +1,220 @@ +"""#2674 — домовая оценка Авито перестаёт врать про ремонт и тип дома. + +Покрывает три дефекта из эпика: + 1. renovation_type берётся из моды listings.repair_state и проходит через + существующий estimator._IMV_REPAIR_MAP (был захардкожен литерал 'cosmetic': + 2685 из 2685 запросов ушли как «косметический ремонт»). + 2. Неизвестный тип дома НЕ уезжает дефолтом 'panel' (самый дешёвый класс → + системное занижение), а помечает дом и экономит запрос. Отдельно — + camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / stalin) + распознаётся, к нижнему регистру он не приводится. + 3. Прогон с saved=0 и ненулевыми errors не помечается 'done'. + +БД и сеть замоканы — реального Postgres/Авито не нужно. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import house_imv_backfill as hib +from app.services.product_handlers import _job_house_imv_backfill + +# ── (1) renovation_type из данных через существующий маппинг ────────────────── + + +def _db_for_pick(listing_row: dict[str, Any], house_row: dict[str, Any] | None) -> MagicMock: + """MagicMock-Session: два .mappings().first() подряд (listings-агрегат, houses).""" + db = MagicMock() + db.execute.return_value.mappings.return_value.first.side_effect = [listing_row, house_row] + return db + + +def _listing_row(**over: Any) -> dict[str, Any]: + base: dict[str, Any] = { + "rooms": 2, + "area_m2": 52.0, + "floor": 3, + "total_floors": 10, + "house_type": "panel", + "repair_state": None, + } + base.update(over) + return base + + +@pytest.mark.parametrize( + ("repair_state", "expected"), + [ + ("needs_repair", "required"), + ("standard", "cosmetic"), + ("good", "euro"), + ("excellent", "designer"), + ], +) +def test_renovation_type_comes_from_listings_via_estimator_map( + repair_state: str, expected: str +) -> None: + """Мода repair_state → renovation_type ровно по estimator._IMV_REPAIR_MAP.""" + from app.services.estimator import _IMV_REPAIR_MAP + + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=repair_state), None), 1) + + assert params["renovation_type"] == expected + # Не второй словарь: значение обязано совпадать с источником правды. + assert params["renovation_type"] == _IMV_REPAIR_MAP[repair_state] + + +def test_renovation_type_not_hardcoded_cosmetic() -> None: + """Regression #2674: 'good' больше не превращается в 'cosmetic'.""" + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state="good"), None), 1) + assert params["renovation_type"] != "cosmetic" + + +def test_unknown_repair_state_stays_cosmetic() -> None: + """Анти-оверрич: ремонт неизвестен → середина шкалы 'cosmetic', дом не теряем.""" + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=None), None), 1) + assert params["renovation_type"] == "cosmetic" + assert params["house_type"] == "panel" # дом всё ещё пригоден к запросу + + +# ── (2) тип дома: неизвестный не врёт, camelCase распознаётся ───────────────── + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + # camelCase из Циана — нижним регистром НЕ лечится (ключ канона через '_'). + ("monolithBrick", "monolithic"), + ("gasSilicateBlock", "block"), + ("aerocreteBlock", "block"), + ("stalin", "brick"), + # каноничные значения продолжают работать + ("panel", "panel"), + ("monolith", "monolithic"), + ("monolith_brick", "monolithic"), + ], +) +def test_map_house_type_recognises_camel_case(raw: str, expected: str) -> None: + assert hib._map_house_type(raw) == expected + + +@pytest.mark.parametrize("raw", [None, "", "other", "wireframe", "какая-то дичь"]) +def test_map_house_type_unknown_is_none_not_panel(raw: str | None) -> None: + """Regression #2674: нет типа / не распознан → None, а НЕ дефолт 'panel'.""" + assert hib._map_house_type(raw) is None + + +def test_pick_lot_params_unknown_house_type_yields_none() -> None: + """Типа нет ни в listings, ни в houses → house_type=None (не 'panel').""" + db = _db_for_pick(_listing_row(house_type=None), {"house_type": None, "total_floors": 9}) + assert hib.pick_lot_params(db, 1)["house_type"] is None + + +@pytest.mark.asyncio +async def test_unknown_house_type_skips_request_and_marks_house() -> None: + """Неизвестный тип → запрос к площадке НЕ уходит, дом помечен no_params.""" + params = { + "rooms": 2, + "area_m2": 52.0, + "floor": 3, + "floor_at_home": 10, + "house_type": None, + "renovation_type": "cosmetic", + "has_balcony": True, + "has_loggia": False, + } + houses = [{"id": 11, "address": "ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}] + db = MagicMock() + + with ( + patch.object(hib, "pick_lot_params", return_value=params), + patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval, + patch.object(hib, "_mark_status") as mock_mark, + ): + db.execute.return_value.mappings.return_value.all.return_value = houses + result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0) + + mock_eval.assert_not_called() + mock_mark.assert_called_once_with(db, 11, "no_params", "unknown house_type") + assert result.skipped == 1 + assert result.saved == 0 + + +# ── (3) прогон с нулём сохранённых и ошибками не «успешен» ──────────────────── + + +class _RunsRecorder: + """Duck-typed ctx.runs: пишет, чем закончился прогон.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + 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.calls.append(("mark_done", counters)) + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.calls.append(("mark_failed", counters)) + + +async def _drive_job(*, saved: int, errors: int, skipped: int = 0) -> list[tuple[str, dict]]: + runs = _RunsRecorder() + enrichment = MagicMock() + enrichment.house_imv_backfill = AsyncMock( + return_value=hib.HouseIMVBackfillResult( + checked=saved + errors + skipped, + saved=saved, + skipped=skipped, + errors=errors, + duration_sec=1.0, + ) + ) + ctx = MagicMock(runs=runs, enrichment=enrichment) + await _job_house_imv_backfill(MagicMock(), 1, {}, ctx) + return runs.calls + + +@pytest.mark.asyncio +async def test_zero_saved_with_errors_is_not_done() -> None: + """Прод-случай: 31 прогон подряд saved=0 / errors≈35 из 50 уходил в 'done'.""" + calls = await _drive_job(saved=0, errors=35, skipped=15) + assert calls[-1][0] == "mark_failed" + assert calls[-1][1]["saved"] == 0 + assert calls[-1][1]["errors"] == 35 + + +@pytest.mark.asyncio +async def test_honest_empty_stays_done() -> None: + """Анти-оверрич: ноль сохранённых без ошибок (всё в skipped) — честная пустота.""" + calls = await _drive_job(saved=0, errors=0, skipped=50) + assert calls[-1][0] == "mark_done" + + +@pytest.mark.asyncio +async def test_partial_success_stays_done() -> None: + """Анти-оверрич: что-то сохранили — прогон успешен, даже если были ошибки.""" + calls = await _drive_job(saved=3, errors=7) + assert calls[-1][0] == "mark_done" + + +@pytest.mark.asyncio +async def test_counters_feed_total_seen_and_new_count() -> None: + """#2674: без этих ключей _column_counts оставляет колонку total_seen=0, + + и алерт «3 подряд done с нулевым результатом» (#2625) выстрелил бы даже на + полностью успешном прогоне. На проде так лежат все 39 прогонов source'а. + """ + from app.services.scrape_runs import _column_counts + + calls = await _drive_job(saved=50, errors=0) + counters = calls[-1][1] + assert _column_counts(counters) == (50, 50) diff --git a/tradein-mvp/backend/tests/test_listing_source_snapshot.py b/tradein-mvp/backend/tests/test_listing_source_snapshot.py index 98d49492..e063bfb6 100644 --- a/tradein-mvp/backend/tests/test_listing_source_snapshot.py +++ b/tradein-mvp/backend/tests/test_listing_source_snapshot.py @@ -104,9 +104,11 @@ def test_event_diff_is_set_based_lateral_not_python_loop() -> None: def test_event_diff_emits_price_change_with_diff_percent() -> None: assert "'price_change'" in _EVENT_DIFF_SQL - # diff_percent = (new-old)/old*100. + # diff_percent = (new-old)/old*100. NULLIF на знаменателе (#2674): выражения + # VALUES вычисляются ДО фильтра e.fires, поэтому предикат "p.price_rub <> 0" + # больше не защищает само деление. assert "(t.price_rub - p.price_rub)" in _EVENT_DIFF_SQL - assert "/ p.price_rub * 100" in _EVENT_DIFF_SQL + assert "/ NULLIF(p.price_rub, 0) * 100" in _EVENT_DIFF_SQL # Only when the price actually changed and old is a usable denominator. assert "t.price_rub <> p.price_rub" in _EVENT_DIFF_SQL assert "p.price_rub <> 0" in _EVENT_DIFF_SQL @@ -200,22 +202,30 @@ def test_migration_079_uses_psycopg_safe_sql() -> None: class _FakeResult: - def __init__(self, rowcount: int) -> None: + def __init__(self, rowcount: int, rows: list[tuple[str, int]] | None = None) -> None: self.rowcount = rowcount + self._rows = rows or [] + + def fetchall(self) -> list[tuple[str, int]]: + """Event-diff statement возвращает пары (counter_key, n) — см. #2674.""" + return self._rows class _FakeDB: """Minimal stand-in for a SQLAlchemy Session — records execute() calls, returns rowcounts.""" - def __init__(self, rowcounts: list[int]) -> None: + def __init__( + self, rowcounts: list[int], event_rows: list[tuple[str, int]] | None = None + ) -> None: self._rowcounts = list(rowcounts) + self._event_rows = event_rows or [] self.executed: list[Any] = [] self.committed = False self.rolled_back = False def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: self.executed.append((stmt, params)) - return _FakeResult(self._rowcounts.pop(0)) + return _FakeResult(self._rowcounts.pop(0), self._event_rows) def commit(self) -> None: self.committed = True @@ -225,7 +235,12 @@ class _FakeDB: def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: - """snapshot_listing_sources maps the two execute() rowcounts to its counters + marks done.""" + """snapshot_listing_sources maps the snapshot rowcount + per-type event rows to counters. + + #2674: event-diff больше не отдаёт один rowcount — внешний SELECT над + data-modifying CTE возвращает пары (counter_key, n) по типам событий, а + counters инициализированы всеми пятью ключами (не сработавший тип = честный 0). + """ marked: dict[str, Any] = {} monkeypatch.setattr( snap_mod.runs_mod, @@ -234,11 +249,16 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: ) monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None) - # rowcounts: SET LOCAL statement_timeout (ignored), snapshot upsert, event-diff insert. - db = _FakeDB(rowcounts=[0, 18355, 42]) + # rowcounts: SET LOCAL statement_timeout (ignored), snapshot upsert, event-diff. + db = _FakeDB(rowcounts=[0, 18355, 0], event_rows=[("price_change_events", 42)]) out = snap_mod.snapshot_listing_sources(db, run_id=99) # type: ignore[arg-type] - assert out == {"snapshotted": 18355, "price_change_events": 42} + assert out == { + "snapshotted": 18355, + "price_change_events": 42, + "edited_events": 0, + "first_seen_events": 0, + } assert db.committed is True assert len(db.executed) == 3 # First statement sets the per-transaction wall-clock budget (#2607). @@ -249,7 +269,7 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: assert params is not None and params["run_id"] == 99 # Run finalised via mark_done with the same counters. assert marked["run_id"] == 99 - assert marked["counters"] == {"snapshotted": 18355, "price_change_events": 42} + assert marked["counters"] == out # ── budget_sec / statement_timeout (#2607) ───────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index 622e2732..e0066101 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -276,64 +276,21 @@ def test_match_house_tier0_cadastr(): address="ул Ленина 5", lat=56.8, lon=60.5, - cadastral_number="66:41:0000000:999", + # Кадастр ЗДАНИЯ — единственный, что принимает ключ дома (#2674). + building_cadastral_number="66:41:0000000:999", ) assert house_id == 42 assert conf == 1.0 assert method == "cadastr_exact" -def test_match_house_tier05_fias_exact(): - """Tier 0.5: house_fias_id match → fias_exact (conf 0.95) before source/fp/geo. - - No cadastral_number supplied → Tier 0 skipped; the fias SELECT is the first - lookup after the advisory lock. On hit, _upsert_house_source + _insert_alias - fire (address carries a house number so the alias is registered). - """ - from app.services.matching.houses import match_or_create_house - - db = _make_db( - [ - None, # pg_advisory_xact_lock - {"id": 55}, # fias_exact hit (Tier 0.5) - None, # _upsert_house_source - None, # _insert_alias - ] - ) - house_id, conf, method = match_or_create_house( - db, - "avito", - "ext-fias-1", - address="ул Ленина 5", - lat=56.8, - lon=60.5, - house_fias_id="0a1b2c3d-0000-4000-8000-000000000001", - ) - assert house_id == 55 - assert conf == 0.95 - assert method == "fias_exact" - - -def test_match_house_fias_skipped_when_absent(): - """No house_fias_id → Tier 0.5 does not run; first lookup is house_sources.""" - from app.services.matching.houses import match_or_create_house - - db = _make_db( - [ - None, # pg_advisory_xact_lock - {"house_id": 8}, # house_sources hit (fias tier skipped, no fias SELECT) - ] - ) - house_id, _conf, method = match_or_create_house( - db, - "avito", - "ext-nofias", - address="пр Мира 10", - lat=56.8, - lon=60.5, - ) - assert house_id == 8 - assert method == "source_exact" +# Тесты Tier 0.5 fias_exact для match_or_create_house удалены вместе с самим тиром (#2674): +# передать туда `house_fias_id` не мог ни один вызывающий (ни Protocol HouseMatcher, ни +# RealMatcherAdapter такого параметра не имели), поэтому за всю историю тир не сработал ни разу — +# 0 из 49 502 строк house_sources. Тесты при этом были зелёными: они звали функцию напрямую и +# передавали параметр сами, минуя границу вызова. Ровно этот класс ошибок теперь сторожит +# tests/test_matching_tier_reachability_2674.py (сверка сигнатуры с границей вызова). +# Живой fias-тир остался в match_house_readonly и покрыт тестами estimate-пути. def test_match_house_tier1_source_exact(): @@ -649,7 +606,9 @@ def test_numberless_address_with_cadastral_creates_house(): "cian", "ext-cad", address="екатеринбург улица мамина сибиряка", - cadastral_number="66:41:0000000:12345", + # Именно кадастр ЗДАНИЯ снимает P1-страж: он идентифицирует дом. Кадастр КВАРТИРЫ этого + # права лишён (#2674) — иначе номер квартиры открывал бы создание мега-бакетов. + building_cadastral_number="66:41:0000000:12345", ) assert (house_id, conf, method) == (321, 1.0, "new") assert any( @@ -885,6 +844,111 @@ def test_tier2a_coord_less_bare_street_still_runs_tier2a(): assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) +# --------------------------------------------------------------------------- +# match_or_create_house — sweep-city guard for BARE oblast cards (#2777) +# --------------------------------------------------------------------------- + + +def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups(): + """RED до #2777. Бескоординатная карточка областного формата («ул. Кирова,4» — города + в адресе НЕТ) собрана развёрткой Серова. Ключ Tier-2a без координат вырождается в один + нормализованный адрес, глобально уникальный в house_address_aliases, поэтому карточка + села бы в одноимённый дом другого города (прод: 362.8 км, дом 380628). Признак города у + развёртки ЕСТЬ — он же пишется в listings.city — и теперь доезжает до стража.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss (Tier 1) + # Tier 2a/2b SKIPPED стражем, Tier 3 SKIPPED (нет координат) + {"id": 2777}, # INSERT RETURNING id (New house) + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, "avito", "ext-2777-serov", address="ул. Кирова,4", city="Серов" + ) + assert (house_id, conf, method) == (2777, 1.0, "new") + sqls = _executed_sqls(db) + assert not any( + "fingerprint = :fp" in s for s in sqls + ), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»" + assert not any("normalized_address = :na" in s for s in sqls) + + +def test_sweep_city_ekb_keeps_tier2a_dedup(): + """Контроль к предыдущему: тот же бескоординатный ключ, но развёртка ЕКБ — страж молчит, + Tier-2a дедуп работает как раньше. Иначе правка ломала бы 31 663 ЕКБ-карточки ради 35.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"house_id": 55}, # Tier 2a fingerprint HIT + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, "avito", "ext-2777-ekb", address="ул. Кирова,4", city="Екатеринбург" + ) + assert (house_id, conf, method) == (55, 0.9, "fingerprint") + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + +def test_sweep_city_ignored_when_card_has_coords(): + """Карточка С координатами стражем города не трогается: у Tier-2a координаты уже в ключе, + у Tier-2b свой гео-страж 3 км. Расширять на неё городской признак — значит ломать + смежные ЕКБ/В.Пышма пары, которые в проде расходятся на 2-8 м (то же здание).""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"house_id": 66}, # Tier 2a fingerprint HIT (координаты в ключе) + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, + "avito", + "ext-2777-coords", + address="ул. Кирова,4", + lat=59.60, + lon=60.58, + city="Верхняя Пышма", + ) + assert (house_id, conf, method) == (66, 0.9, "fingerprint") + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + +def test_sweep_city_guard_covers_cities_outside_the_token_list(): + """Страж не должен зависеть от списка _CITY_TOKENS: развёртка Ревды (её в списке нет) + всё равно не имеет права садиться на чужой алиас. Признак — имя города от развёртки, + а не токен, вычитанный из адреса.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"id": 2778}, # INSERT RETURNING id (New house) — алиасы пропущены + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, _conf, method = match_or_create_house( + db, "avito", "ext-2777-revda", address="ул. Кирова,4", city="Ревда" + ) + assert (house_id, method) == (2778, "new") + assert not any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + # --------------------------------------------------------------------------- # match_or_create_listing — mock DB tier routing # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py new file mode 100644 index 00000000..b16be85b --- /dev/null +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -0,0 +1,133 @@ +"""Достижимость тиров матчинга домов (#2674). + +Эпик #2674 — «код написан, отревьюен, смержен и ни разу не сработал». Два верхних тира +`match_or_create_house` были именно такими: за всю историю 0 из 49 502 строк house_sources +имеют matched_method 'cadastr_exact' или 'fias_exact'. + +Причина у fias-тира структурная: параметр `house_fias_id` существовал только в сигнатуре +самой функции — ни Protocol `scraper_kit.contracts.HouseMatcher`, ни продуктовый адаптер +`RealMatcherAdapter`, ни прямые вызывающие его не имели, т.е. передать значение было НЕКОМУ. +Обычный тест этого не ловит: он вызывает функцию напрямую и передаёт параметр сам. + +Ловит — сверка сигнатуры с границей вызова. Здесь она и живёт: каждый keyword-параметр +матчера обязан быть выразим у вызывающего, иначе ветка недостижима в проде. +""" + +from __future__ import annotations + +import inspect +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.contracts import HouseMatcher + +from app.services.matching.houses import match_house_readonly, match_or_create_house +from app.services.scraper_adapters import RealMatcherAdapter + + +def _params(func: object) -> set[str]: + return set(inspect.signature(func).parameters) - {"self", "db"} + + +def test_create_path_matcher_params_are_all_reachable_from_the_boundary() -> None: + """Каждый параметр `match_or_create_house` должен быть передаваем через границу. + + Границы две, и обе обязаны выражать полный набор: + * Protocol `HouseMatcher` — контракт, который видит scraper-kit; + * `RealMatcherAdapter` — единственная боевая реализация этого контракта. + + Параметр, которого нет ни там ни там, — недостижимая ветка (случай `house_fias_id`, + #2674). RED до фикса: {'house_fias_id'}. + """ + impl = _params(match_or_create_house) + unreachable_via_protocol = impl - _params(HouseMatcher.match_or_create_house) + unreachable_via_adapter = impl - _params(RealMatcherAdapter.match_or_create_house) + + assert not unreachable_via_protocol, ( + f"параметры матчера, недостижимые через Protocol HouseMatcher: " + f"{sorted(unreachable_via_protocol)} — ветка не сработает в проде" + ) + assert not unreachable_via_adapter, ( + f"параметры матчера, недостижимые через RealMatcherAdapter: " + f"{sorted(unreachable_via_adapter)} — ветка не сработает в проде" + ) + + +def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None: + """Fias-тир удалён там, где источника ФИАС нет, и сохранён там, где он есть. + + `match_house_readonly` зовёт estimator.resolve_target_house и передаёт ФИАС из + payload.target_fias_id / DaData — источник реальный, тир оставляем. + """ + assert "house_fias_id" not in _params(match_or_create_house) + assert "house_fias_id" in _params(match_house_readonly) + + # Ищем именно литерал method-значения ('"fias_exact"'), а не слово в комментарии: + # комментарий-надгробие про удалённый тир остаться должен, ветка — нет. + assert '"fias_exact"' not in inspect.getsource( + match_or_create_house + ), "мёртвая fias-ветка вернулась в путь создания домов" + assert '"fias_exact"' in inspect.getsource(match_house_readonly) + + +def test_readonly_fias_tier_is_actually_fed_by_its_caller() -> None: + """Единственный живой ФИАС-тир должен ПОЛУЧАТЬ аргумент, а не только объявлять параметр. + + Проверки выше — структурные: они видят имя в сигнатуре. Этого мало. Уберут аргумент на + настоящей границе вызова (`estimator.estimate_quality` → `match_house_readonly`) — сигнатура + останется прежней, оба теста выше зелёные, а тир снова мёртв, и мы снова здесь. + + Доказательство, что структурной проверки недостаточно, лежит рядом: `building_cadastral_number` + её проходит и при этом сработал 0 раз из 49 502. + """ + from app.services.estimator import estimate_quality + + src = inspect.getsource(estimate_quality) + assert "match_house_readonly" in src, "estimate_quality больше не зовёт read-only матчер" + assert "house_fias_id=" in src, ( + "estimate_quality перестал передавать house_fias_id — единственный живой fias-тир " + "снова недостижим (это и есть класс ошибки #2674)" + ) + + +def test_house_key_never_accepts_flat_cadastre() -> None: + """Ключ ДОМА строится только на кадастре здания — не на кадастре квартиры (#2674). + + Ловушка «оставленного рабочего приёмника»: у квартиры свой кадастр, поэтому Tier 0 по нему + не сматчит НИКОГДА, упадёт в New-house INSERT и проштампует номер квартиры в + houses.cadastral_number; плюс `cad` снимает P1-страж «безномерный адрес без кадастра не + создаём». Две квартиры одного дома → два дома. RED до фикса: параметр был в сигнатуре, а + `cad = building_cadastral_number or cadastral_number`. + """ + assert "cadastral_number" not in _params(match_or_create_house) + assert "building_cadastral_number" in _params(match_or_create_house) + + src = inspect.getsource(match_or_create_house) + assert ( + "cad = building_cadastral_number\n" in src + ), "в ключ дома вернулся фолбэк на кадастр квартиры" + + +def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None: + """Страж города бесполезен, пока признак не доезжает до него с настоящего вызова (#2777). + + Тот же класс ошибки, что у `house_fias_id`: параметр в сигнатуре есть, передать его + некому. Здесь граница — `scraper_kit.base._link_listing_to_house`, единственный боевой + вызывающий пути создания домов; город он получает из `save_listings`, который его же + пишет в `listings.city`. + """ + from scraper_kit.base import _link_listing_to_house, save_listings + + assert "city" in _params(match_or_create_house) + + hook_src = inspect.getsource(_link_listing_to_house) + assert "city=city" in hook_src, ( + "_link_listing_to_house перестал передавать город в матчер — страж #2777 снова " + "недостижим, а межгородская склейка молча вернётся" + ) + # save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать + # именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен. + assert "city=lot_city" in inspect.getsource( + save_listings + ), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён" diff --git a/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py b/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py new file mode 100644 index 00000000..43e40818 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py @@ -0,0 +1,207 @@ +"""Static guards для миграции 211 (сегментный гард #1186 в street_sales_vs_listings(), #2660). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP), полный DB-прогон требует живой +БД — здесь фиксируем структурные инварианты: + - транзакционность + идемпотентность (CREATE OR REPLACE, без DROP/TRUNCATE); + - гард #1186 стоит именно в window_listings (listings-сторона), а не в window_deals + (у deals нет listing_segment) и не в paired; + - `is_active` НЕ появился — его отсутствие осознанно (снятые объявления и есть + материал пейринга), см. шапку 205/211; + - сигнатура НЕ изменилась относительно 205 — иначе CREATE OR REPLACE создаст ВТОРУЮ + перегрузку вместо замены (грабли #2627), и caller получит старое тело; + - city-предикаты #2583 H4 (обе стороны JOIN) на месте — не потеряны при переносе тела; + - RETURNS TABLE не переименована — endpoint читает колонки по имени. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_205 = _SQL_DIR / "205_sales_vs_listings_city_filter.sql" +_MIGRATION_211 = _SQL_DIR / "211_sales_vs_listings_segment_guard.sql" + +_SIGNATURE_TYPES = ("text", "numeric", "integer", "integer", "numeric", "integer", "text") +_SEGMENT_GUARD = "(l.listing_segment is null or l.listing_segment = 'vtorichka')" + + +def _sql(path: Path = _MIGRATION_211) -> str: + return path.read_text(encoding="utf-8") + + +def _executable_sql(path: Path = _MIGRATION_211) -> 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 _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def _function_body(path: Path = _MIGRATION_211) -> str: + """Тело функции между `AS $$` и `$$;`, без `--`-комментариев, нормализованное. + + Отдельно от _executable_sql(): COMMENT ON FUNCTION — тоже исполняемый SQL, и его + текст описывает фильтры словами (в т.ч. «is_active намеренно не фильтруется»). + Проверки «такого предиката в запросе нет» обязаны смотреть только на тело. + """ + sql = _sql(path) + start = sql.index("AS $$") + end = sql.index("$$;", start) + body = "\n".join( + line.split("--", 1)[0] for line in sql[start:end].splitlines() if line.split("--", 1)[0] + ) + return _flat(body) + + +def _function_signature(path: Path) -> str: + """Блок `CREATE OR REPLACE FUNCTION ...(...)` до RETURNS TABLE, нормализованный.""" + sql = _sql(path) + start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(") + end = sql.index("RETURNS TABLE", start) + return _flat(sql[start:end]) + + +def test_migration_211_exists() -> None: + assert _MIGRATION_211.exists(), f"missing migration: {_MIGRATION_211}" + + +def test_migration_211_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_211_no_destructive_ddl() -> None: + """Только CREATE OR REPLACE FUNCTION + COMMENT — таблицы не трогаем.""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "alter table" not in flat + assert "truncate" not in flat + assert "delete from" not in flat + + +def test_migration_211_segment_guard_present_in_window_listings() -> None: + """Гард #1186 — канонический предикат, НЕ `= 'vtorichka'` без NULL-ветки + (NULL = legacy вторичка до м.011; прод: 1 557 таких кандидатов).""" + flat = _flat(_executable_sql()) + assert _SEGMENT_GUARD in flat + # Гард стоит в listings-CTE, а не где-то ниже по тексту (в paired/финальном SELECT). + window_listings_pos = flat.index("window_listings as") + paired_pos = flat.index("paired as") + guard_pos = flat.index(_SEGMENT_GUARD) + assert window_listings_pos < guard_pos < paired_pos + + +def test_migration_211_does_not_add_is_active_filter() -> None: + """Отсутствие is_active — ОСОЗНАННОЕ (#2660): функция намеренно смотрит и снятые + объявления, иначе к сделке нечего подставить (объявление снимают ПОСЛЕ продажи).""" + assert "is_active" not in _function_body() + + +def test_migration_211_does_not_add_freshness_filter() -> None: + """Свежесть здесь тоже не при чём: пейринг привязан к дате СДЕЛКИ (window_days), + а не к «сейчас». Регресс-гард против копипасты предиката из location_index.""" + body = _function_body() + assert "l.scraped_at > now()" not in body + assert "last_seen_at" not in body + + +def test_migration_211_signature_identical_to_205_no_new_overload() -> None: + """#2627: CREATE OR REPLACE с ИЗМЕНЁННЫМ списком типов создаёт ВТОРУЮ перегрузку, + а не замену — caller продолжит попадать в старое тело. Сигнатура 211 обязана + совпадать с 205 побайтово (после нормализации пробелов).""" + assert _function_signature(_MIGRATION_211) == _function_signature(_MIGRATION_205) + + +def test_migration_211_does_not_drop_current_signature() -> None: + """DROP текущей 7-арг сигнатуры не нужен (замена и так in-place) и вреден — + между DROP и CREATE caller получил бы `function does not exist`.""" + flat = _flat(_executable_sql()) + new_sig = "street_sales_vs_listings(" + ", ".join(_SIGNATURE_TYPES) + ")" + assert f"drop function if exists {new_sig}" not in flat + assert "drop function" not in flat + + +def test_migration_211_exactly_one_function_definition() -> None: + """Ровно одно CREATE OR REPLACE — иначе последнее молча перетрёт предыдущее.""" + flat = _flat(_executable_sql()) + assert flat.count("create or replace function street_sales_vs_listings") == 1 + + +def test_migration_211_comment_targets_the_7arg_signature() -> None: + """COMMENT ON FUNCTION со списком типов — иначе комментарий сядет на + несуществующий оверлоад / упадёт на ambiguity.""" + flat = _flat(_executable_sql()) + new_sig = "street_sales_vs_listings(" + ", ".join(_SIGNATURE_TYPES) + ")" + assert f"comment on function {new_sig}" in flat + + +def test_migration_211_keeps_city_predicates_from_205() -> None: + """Тело переносится целиком — city-скоуп #2583 H4 (обе стороны JOIN) не потерян.""" + flat = _flat(_executable_sql()) + assert "(p_target_city is null or lower(d.city) = lower(p_target_city))" in flat + assert ( + "(p_target_city is null or l.city is null or lower(l.city) = lower(p_target_city))" in flat + ) + + +def test_migration_211_return_table_shape_unchanged() -> None: + """RETURNS TABLE читается endpoint'ом по имени через .mappings() — любое + переименование/удаление сломало бы response mapping без явного сигнала.""" + sql = _sql() + for col in ( + "deal_id", + "deal_date", + "deal_price_rub", + "deal_price_per_m2", + "deal_area_m2", + "deal_rooms", + "deal_floor", + "deal_address", + "listing_id", + "listing_source", + "listing_source_url", + "listing_date", + "listing_price_rub", + "listing_price_per_m2", + "listing_area_m2", + "days_listing_to_deal", + "discount_pct", + ): + assert col in sql, f"missing column in RETURNS TABLE: {col}" + + +def test_migration_211_no_psycopg_cast_trap() -> None: + """Регресс-гард против copy-paste `:param::type` (в чистом .sql bind-параметров нет, + но файл переносится в Python-тесты/скрипты).""" + assert not re.search(r":\w+::", _sql()) + + +def test_migration_211_body_matches_205_except_the_guard() -> None: + """Тело — байт-в-байт копия 205 плюс ровно один новый предикат. Защищает от + случайной потери логики при ручном переносе большого SQL-блока.""" + + def body(path: Path) -> list[str]: + sql = _sql(path) + start = sql.index("AS $$") + end = sql.index("$$;", start) + # Только исполняемые строки, без комментариев — их мы правим осознанно. + return [ + _flat(line.split("--", 1)[0]) + for line in sql[start:end].splitlines() + if line.split("--", 1)[0].strip() + ] + + old_body = body(_MIGRATION_205) + new_body = body(_MIGRATION_211) + added = [line for line in new_body if line not in old_body] + removed = [line for line in old_body if line not in new_body] + assert added == ["and " + _SEGMENT_GUARD], added + assert removed == [], removed diff --git a/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py new file mode 100644 index 00000000..434d419f --- /dev/null +++ b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py @@ -0,0 +1,103 @@ +"""Payments retention (PR #2754) — "12 месяцев" text sync guard. + +WHY: + mera-public/content.ts declares itself as the ONE place product promises + live (docstring at the top of that file: "ни одного утверждения, которого + не делает код"). The public retention promise ("оплаченный отчёт хранится + N месяцев") has THREE places it could quietly drift: the backend setting + (`settings.trade_in_paid_retention_days`), the frontend constant + (`PAID_REPORT_RETENTION_MONTHS` in content.ts), and any page that renders + it (today: privacy/page.tsx). Deep-review finding 2026-08-06 MEDIUM on + PR #2754 caught exactly this: a comment claimed the number "reads from the + same setting" while the page actually hardcoded a `12 месяцев` literal -- + a comment cannot fail CI, same lesson as + test_consent_text_frontend_sync.py's _CONSENT_TEXT_SNAPSHOT guard (which + this file mirrors). + +WHAT: + 1. privacy/page.tsx imports PAID_REPORT_RETENTION_MONTHS from content.ts + and does NOT hardcode a "N месяцев" literal of its own. + 2. The frontend months constant and the backend days setting stay within + a sane calendar tolerance of each other (28-31 days per month) -- this + does NOT enforce byte-identity (days and months are different units by + design, see content.ts docstring), only that nobody silently changes + one without the other drifting out of "still honestly ~12 months". +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_FRONTEND_CONTENT = ( + Path(__file__).resolve().parents[2] / "frontend" / "src" / "app" / "mera-public" / "content.ts" +) +_FRONTEND_PRIVACY_PAGE = ( + Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "app" + / "mera-public" + / "privacy" + / "page.tsx" +) + +_MONTHS_CONST_RE = re.compile(r"PAID_REPORT_RETENTION_MONTHS\s*=\s*(\d+)\s*;") +_LITERAL_MONTHS_RE = re.compile(r'"?\d+ месяцев"?') + + +def _extract_months_constant(content_ts_source: str) -> int: + match = _MONTHS_CONST_RE.search(content_ts_source) + assert match is not None, ( + "PAID_REPORT_RETENTION_MONTHS not found in content.ts -- " + "constant renamed/removed without updating this test" + ) + return int(match.group(1)) + + +def test_frontend_files_exist() -> None: + assert _FRONTEND_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_CONTENT}" + assert _FRONTEND_PRIVACY_PAGE.is_file(), f"missing frontend file: {_FRONTEND_PRIVACY_PAGE}" + + +def test_privacy_page_imports_retention_constant_not_hardcoded() -> None: + """The whole point: FAILS if privacy/page.tsx stops importing the shared + constant and goes back to a hardcoded '12 месяцев' literal (exactly the + drift the deep-review finding caught -- comment said 'reads from content.ts', + code said otherwise).""" + src = _FRONTEND_PRIVACY_PAGE.read_text(encoding="utf-8") + assert "PAID_REPORT_RETENTION_MONTHS" in src, ( + "privacy/page.tsx no longer references PAID_REPORT_RETENTION_MONTHS -- " + "the retention promise must be rendered from the shared content.ts " + "constant, not written out again by hand" + ) + assert re.search(r'from\s+"\.\./content"', src), ( + "privacy/page.tsx must import from '../content' (content.ts), where " + "PAID_REPORT_RETENTION_MONTHS is declared" + ) + literal_hits = _LITERAL_MONTHS_RE.findall(src) + assert not literal_hits, ( + "privacy/page.tsx contains a hardcoded 'N месяцев' literal -- render the " + "PAID_REPORT_RETENTION_MONTHS constant instead: " + f"{literal_hits!r}" + ) + + +def test_backend_days_setting_matches_frontend_months_within_calendar_tolerance() -> None: + """Not byte-identity (days vs months are different units, deliberately -- + see content.ts docstring on PAID_REPORT_RETENTION_MONTHS): just a sanity + bound that `trade_in_paid_retention_days` still honestly rounds to the + number of months the public page promises (28-31 days/month, generous).""" + from app.core.config import settings + + days = settings.trade_in_paid_retention_days + months = _extract_months_constant(_FRONTEND_CONTENT.read_text(encoding="utf-8")) + + assert 28 * months <= days <= 31 * months, ( + f"settings.trade_in_paid_retention_days={days} no longer honestly rounds to " + f"content.ts PAID_REPORT_RETENTION_MONTHS={months} -- update both together " + "(and the offer text, when it exists) so the public promise stays true" + ) diff --git a/tradein-mvp/backend/tests/test_password.py b/tradein-mvp/backend/tests/test_password.py index 5cdfa31e..a03e5c9f 100644 --- a/tradein-mvp/backend/tests/test_password.py +++ b/tradein-mvp/backend/tests/test_password.py @@ -2,9 +2,27 @@ from __future__ import annotations +import asyncio +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +# С #2665 password.py читает настройки (размер пула проверок) — значит тянет +# `Settings()`, которому нужен DATABASE_URL. В CI он в env (ci-tradein.yml), +# локально подставляем заглушку, как это делает tests/test_auth_api.py. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + import pytest -from app.core.password import hash_password, verify_password +from app.core import password as password_mod +from app.core.config import settings +from app.core.password import ( + PasswordVerifyOverloadedError, + hash_password, + verify_password, + verify_password_bounded, +) def test_roundtrip() -> None: @@ -77,3 +95,293 @@ def test_hash_is_unique_due_to_salt() -> None: def test_verify_malformed_hash_returns_false() -> None: """Некорректный (не-bcrypt) хеш в verify_password → False, не raise.""" assert verify_password("some password", "not-a-bcrypt-hash") is False + + +# --------------------------------------------------------------------------- +# #2665 — verify_password_bounded: вне событийного цикла + потолок темпа +# --------------------------------------------------------------------------- + + +def test_verify_ceiling_defaults_stay_within_the_db_pool() -> None: + """Дефолты — часть защиты, а не тюнинг. Стережём их здесь. + + Тест про темп (test_auth_api.py) вычисляет ожидаемый потолок из той же + настройки, которую охраняет, поэтому подъём дефолта он не заметит. А + наступит ослабление именно через настройку: не правкой кода и не ревью, а + строчкой `LOGIN_PASSWORD_VERIFY_WORKERS=32` в `.env.runtime` под предлогом + «входы тормозят». Пусть тогда краснеет хотя бы этот тест. + """ + from app.core.db import engine + + # max_inflight ждущих ДЕРЖАТ по соединению к БД (сессия реестра открыта + # после SELECT в get_user_by_username) — очередь обязана быть уже пула. + assert ( + settings.login_password_verify_max_inflight < engine.pool.size() + engine.pool._max_overflow + ) + assert settings.login_password_verify_workers == 1, ( + "потолок перебора = workers/282мс. Подъём — осознанное решение " + "«во сколько раз ускоряем перебор», а не рефакторинг: правь вместе с тестом" + ) + # ЛИТЕРАЛЫ, а не арифметика от настройки. Доля на ключ (#2714) считается как + # max_inflight // 2, и сторож вида `cap == max_inflight // 2` был бы + # тавтологией: подъём max_inflight до 64 он бы проспал, а вместе с ним — + # возврат к «один адрес занимает всё» (доля 32 при очереди в 4 живых слота + # ничего не делит). Поэтому здесь зафиксированы ОБА числа. + assert settings.login_password_verify_max_inflight == 4, ( + "очередь 4 выбрана под QueuePool 5+10 и худшее ожидание 4/1×282мс ≈ 1.1с; " + "меняешь — пересчитывай и долю на ключ ниже" + ) + assert password_mod._per_key_slot_cap() == 2, ( + "один адрес держит не больше 2 слотов из 4: половина ёмкости обязана " + "оставаться тем, кто приходит впервые (#2714)" + ) + + +async def test_bounded_gives_same_answer_as_sync() -> None: + """Обёртка не меняет вердикт — она меняет только ГДЕ он считается.""" + hashed = hash_password("correct horse battery staple") + key = "203.0.113.1" + assert await verify_password_bounded("correct horse battery staple", hashed, key=key) is True + assert await verify_password_bounded("wrong password", hashed, key=key) is False + + +async def test_bounded_runs_off_the_event_loop_thread(monkeypatch: pytest.MonkeyPatch) -> None: + """bcrypt считается В ДРУГОМ ПОТОКЕ, а не в потоке событийного цикла. + + Замер на проде: сверка = 282 мс, и ровно столько цикл не обслуживал никого. + Проверка идентичности потока — самая прямая формулировка «цикл свободен»; + таймингом её подменять нельзя, тайминг в CI флейкует. + """ + loop_thread = threading.get_ident() + seen: list[int] = [] + + def _spy(plain: str, hashed: str) -> bool: + seen.append(threading.get_ident()) + return True + + monkeypatch.setattr(password_mod, "verify_password", _spy) + assert await verify_password_bounded("x", "y", key="203.0.113.1") is True + assert seen and seen[0] != loop_thread + + +async def test_bounded_rejects_surplus_instead_of_queueing(monkeypatch: pytest.MonkeyPatch) -> None: + """Сверх лимита — немедленный отказ, а не ожидание в очереди. + + Ожидание выглядело бы безобиднее, но каждый ждущий запрос держит соединение + к БД (сессия реестра открыта после SELECT), а в QueuePool их 5+10: + неограниченная очередь выбрала бы пул и положила API — тем же концом, каким + его клала блокировка цикла. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 2) + + def _slow(plain: str, hashed: str) -> bool: + time.sleep(0.2) + return False + + monkeypatch.setattr(password_mod, "verify_password", _slow) + + # У каждого запроса СВОЙ ключ: тест про ОБЩИЙ потолок, и отказывать здесь + # обязан именно он. С одним ключом на всех первым сработал бы лимит доли + # (#2714) — числа сошлись бы по другой причине, а поломка общего потолка + # осталась бы незамеченной. + results = await asyncio.gather( + *(verify_password_bounded("x", "y", key=f"203.0.113.{i}") for i in range(6)), + return_exceptions=True, + ) + rejected = [r for r in results if isinstance(r, PasswordVerifyOverloadedError)] + admitted = [r for r in results if r is False] + assert len(admitted) == 2, results + assert len(rejected) == 4, results + + # Слоты возвращаются: после отработки очереди вход снова доступен. + assert await verify_password_bounded("x", "y", key="203.0.113.9") is False + + +async def test_bounded_slot_freed_by_the_work_not_by_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Отмена запроса не возвращает слот раньше времени. + + Отмена снимает работу, которая ещё НЕ началась, — с ней проблем нет. Но уже + начатую сверку она не забирает: поток занят ею все 282 мс. Освобождай мы + слот по выходу из корутины, на это время он числился бы свободным, и + одновременно работающих сверок стало бы больше, чем разрешено. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 1) + started = threading.Event() + finish = threading.Event() + + def _blocked(plain: str, hashed: str) -> bool: + started.set() + finish.wait(5) + return False + + monkeypatch.setattr(password_mod, "verify_password", _blocked) + + task = asyncio.create_task(verify_password_bounded("x", "y", key="203.0.113.1")) + await asyncio.to_thread(started.wait, 5) + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Работа всё ещё занимает поток — слот занят, следующий получает отказ. + # Ключ ДРУГОЙ: отказ обязан прийти от общего потолка (max_inflight=1), а не + # от доли на ключ — иначе тест проверял бы не тот механизм. + with pytest.raises(PasswordVerifyOverloadedError): + await verify_password_bounded("x", "y", key="203.0.113.2") + + finish.set() + for _ in range(100): # дать колбэку доехать до цикла + await asyncio.sleep(0.01) + if settings.login_password_verify_max_inflight > password_mod._verify_inflight: + break + # Отменённая работа вернула И общий слот, И слот своего ключа: тот же адрес + # снова обслуживается (утечка по ключу при пуле в 1 поток была бы вечным + # отказом именно этому адресу и больше ничем себя не проявила). + assert await verify_password_bounded("x", "y", key="203.0.113.1") is False + + +async def test_bounded_frees_slot_when_verify_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Исключение внутри сверки тоже возвращает слот — оба счётчика. + + `verify_password` глотает ValueError/TypeError сама, так что сюда доезжает + только неожиданное (падение библиотеки, MemoryError). Пул из ОДНОГО потока + не прощает: один невозвращённый слот — вечный 429 всем на входе, и внешне + это выглядит не как ошибка bcrypt, а как «вход сломался неизвестно почему». + """ + + def _boom(plain: str, hashed: str) -> bool: + raise MemoryError("bcrypt died") + + monkeypatch.setattr(password_mod, "verify_password", _boom) + with pytest.raises(MemoryError): + await verify_password_bounded("x", "y", key="10.0.0.3") + + monkeypatch.setattr(password_mod, "verify_password", lambda plain, hashed: False) + assert await verify_password_bounded("x", "y", key="10.0.0.3") is False + assert password_mod._verify_inflight == 0 + assert not password_mod._verify_inflight_by_key + + +async def test_one_key_cannot_take_more_than_its_share(monkeypatch: pytest.MonkeyPatch) -> None: + """Один ключ занимает не больше своей доли — остальным ёмкость остаётся (#2714). + + Меряем именно ЭТО, а не латентность: с общим котлом слотов один источник + выбирал его целиком, и вход с другого адреса получал 429 бессрочно — + потолок темпа исправно работал против легитимных пользователей. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 4) + assert password_mod._per_key_slot_cap() == 2 # 4 // 2 — исходные условия теста + + finish = threading.Event() + + def _blocked(plain: str, hashed: str) -> bool: + finish.wait(5) + return False + + monkeypatch.setattr(password_mod, "verify_password", _blocked) + + async def _wait_inflight(n: int) -> None: + deadline = time.monotonic() + 5 + while password_mod._verify_inflight < n: + assert ( + time.monotonic() < deadline + ), f"слотов занято {password_mod._verify_inflight} < {n}" + await asyncio.sleep(0.005) + + flood = [ + asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.1")) for _ in range(2) + ] + await _wait_inflight(2) + + # Третий с ТОГО ЖЕ адреса — отказ, хотя два слота из четырёх свободны. + # Это ЦЕНА правки, а не побочный эффект: три одновременных входа из одного + # офиса за NAT укладываются в окно одной сверки (282 мс), и третьему + # сотруднику теперь отказывают при наполовину пустом пуле — до правки для + # этого требовалось пятеро. Закрепляем явно, чтобы размен нельзя было + # потерять молча: свои с ЧУЖИХ адресов за это получают 100% вместо 37%. + assert password_mod._verify_inflight == 2 + assert settings.login_password_verify_max_inflight == 4 + with pytest.raises(PasswordVerifyOverloadedError): + await verify_password_bounded("x", "y", key="10.0.0.1") + + # А с другого — пускают. Задачу ставим ДО finish.set() и ждём, пока она + # займёт слот: иначе «пустили» означало бы только «флуд успел закончиться». + legit = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.2")) + await _wait_inflight(3) + + finish.set() + assert await legit is False, "вход с другого адреса обязан пройти во время флуда" + assert [await f for f in flood] == [False, False] + + +def test_per_key_cap_never_rounds_down_to_zero(monkeypatch: pytest.MonkeyPatch) -> None: + """При очереди в 1 слот доля не округляется в ноль. + + `1 // 2 == 0` означало бы «ни одному ключу нельзя ни одного слота» — + молчаливый отказ ВСЕМ на входе, причём тем более незаметный, что настройка + выглядит как безобидное ужесточение. `max(1, …)` — тот же страховочный пол, + что `ge=1` у самой настройки, только от деления. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 1) + assert password_mod._per_key_slot_cap() == 1 + + +async def test_bounded_frees_slot_when_pool_refuses_work(monkeypatch: pytest.MonkeyPatch) -> None: + """Пул не принял работу → слот отдан прямо здесь, колбэка ведь не будет. + + Единственный путь, где освобождение НЕ висит на future: `submit` бросает + (пул закрыт на остановке процесса). Утечка тут стоила бы дорого — при пуле + в один поток невозвращённый слот это вечный 429 всем на входе. + """ + dead_pool = ThreadPoolExecutor(max_workers=1) + dead_pool.shutdown() + monkeypatch.setattr(password_mod, "_VERIFY_POOL", dead_pool) + + with pytest.raises(RuntimeError): + await verify_password_bounded("x", "y", key="10.0.0.4") + + assert password_mod._verify_inflight == 0 + assert not password_mod._verify_inflight_by_key + + +async def test_cancelling_queued_work_returns_the_key_slot(monkeypatch: pytest.MonkeyPatch) -> None: + """Отмена ЕЩЁ НЕ НАЧАТОЙ работы возвращает слот — и общий, и ключа. + + Ветка future другая, чем у отмены начатой работы (`cancel()` на очереди + успевает, и работа не исполняется вовсе), поэтому проверяется отдельно: + соседний тест про начатую работу эту не покрывает. Пул из одного потока — + настоящий, так что второй запрос гарантированно ЖДЁТ в очереди. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 4) + started = threading.Event() + finish = threading.Event() + + def _blocked(plain: str, hashed: str) -> bool: + started.set() + finish.wait(5) + return False + + monkeypatch.setattr(password_mod, "verify_password", _blocked) + + running = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.5")) + await asyncio.to_thread(started.wait, 5) + + queued = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.6")) + deadline = time.monotonic() + 5 + while password_mod._verify_inflight_by_key.get("10.0.0.6") != 1: + assert time.monotonic() < deadline, "второй запрос не занял слот" + await asyncio.sleep(0.005) + + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + + deadline = time.monotonic() + 5 + while "10.0.0.6" in password_mod._verify_inflight_by_key: + assert time.monotonic() < deadline, "слот отменённой очереди не вернулся" + await asyncio.sleep(0.005) + + finish.set() + assert await running is False diff --git a/tradein-mvp/backend/tests/test_password_call_sites.py b/tradein-mvp/backend/tests/test_password_call_sites.py new file mode 100644 index 00000000..48e37a9d --- /dev/null +++ b/tradein-mvp/backend/tests/test_password_call_sites.py @@ -0,0 +1,110 @@ +"""Правило «из `async def` зови ТОЛЬКО ограниченную сверку» — проверяемое (#2715). + +Правило живёт в docstring `app/core/password.py`: синхронный `verify_password` +блокирует поток на ~282 мс (bcrypt cost 12), поэтому из кода приложения его +зовёт РОВНО ОДНА функция — `verify_password_bounded`, и она же единственная, +кто считает слоты (потолок темпа #2665 + доля на ключ #2714). + +Комментарий это правило не удерживает. Синхронная функция остаётся публичной и +импортируемой, и достаточно одной строчки `asyncio.to_thread(verify_password, +…)` в будущем коде, чтобы получить вынос в поток ВООБЩЕ БЕЗ учёта слотов: +внешне всё работает, вход отвечает быстро, а потолок перебора тихо исчезает. +Ревью такое ловит ровно до тех пор, пока помнит, что правило есть. + +Прецедент такого сторожа в репозитории: backend/tests/sql/test_auth_sql_migrations.py. + +ПОЧЕМУ AST, А НЕ GREP. `verify_password` упоминается в комментариях и docstring'ах +(app/api/v1/auth.py, app/core/config.py) — текстовый поиск краснел бы на них, и +сторож пришлось бы ослаблять исключениями до бессмысленности. AST видит только +ССЫЛКИ НА СИМВОЛ и ловит форму без скобок (`to_thread(verify_password, …)`), +которую `grep 'verify_password('` не поймал бы вовсе — то есть ровно ту, ради +которой сторож и написан. + +ЧЕГО СТОРОЖ НЕ ВИДИТ, и это записано тут, а не подразумевается: строкового +доступа (`getattr(mod, "verify_password")`) и обхода модуля целиком (прямой +`bcrypt.checkpw`). От НАМЕРЕННОГО обхода он не защищает и не может — только от +нечаянного, а нечаянный и есть частый случай. Обе непойманные формы закреплены +исполняемо (`test_detector_blind_spots_are_known`), чтобы «не ловим» было +проверенным фактом, а не обещанием в тексте. + +Без БД и без сети — только чтение файлов. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_APP_DIR = _BACKEND_ROOT / "app" +# Единственное место, которому синхронная сверка разрешена: там она и определена, +# и оттуда её забирает пул внутри `verify_password_bounded`. +_OWNER = _APP_DIR / "core" / "password.py" + + +def _references_verify_password(source: str) -> bool: + """Ссылается ли модуль на символ `verify_password` (в любой форме).""" + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Name) and node.id == "verify_password": + return True + if isinstance(node, ast.Attribute) and node.attr == "verify_password": + return True + if isinstance(node, ast.ImportFrom) and any( + alias.name == "verify_password" for alias in node.names + ): + return True + return False + + +def test_detector_actually_detects() -> None: + """Сторож обязан уметь краснеть — иначе он зелен вхолостую. + + Проверка на самого себя: пустой детектор (`return False`) прошёл бы все + файлы приложения и выглядел бы работающим сторожем ровно до первого + настоящего нарушения. + """ + # Формы, которые обязан ловить. + assert _references_verify_password("from app.core.password import verify_password") + assert _references_verify_password("asyncio.to_thread(verify_password, plain, hashed)") + assert _references_verify_password("password.verify_password(plain, hashed)") + assert _references_verify_password("ok = verify_password(plain, hashed)") + + # Формы, на которые краснеть НЕЛЬЗЯ (иначе сторож потребуют выключить). + assert not _references_verify_password("await verify_password_bounded(p, h, key=ip)") + assert not _references_verify_password('"""Зови verify_password только из пула."""') + assert not _references_verify_password("# verify_password тут только в комментарии") + + +def test_detector_blind_spots_are_known() -> None: + """Слепые зоны — зафиксированы, а не забыты. + + Обе формы обходят сторож НАМЕРЕННЫМ усилием: строковый доступ к атрибуту и + обход модуля целиком. Ловить их AST'ом можно было бы только ценой ложняков + (любой `getattr` с любой строкой, любой вызов bcrypt), а цена ложняка — + требование выключить сторож. Тест держит это знание исполняемым: захочет + однажды детектор их ловить — покраснеет здесь и заставит осознанно + переписать и этот тест, и текст модуля. + """ + assert not _references_verify_password('fn = getattr(password_mod, "verify_password")') + assert not _references_verify_password("bcrypt.checkpw(plain.encode(), hashed.encode())") + + +def test_sync_verify_password_is_called_from_one_place_only() -> None: + """В `app/` синхронную сверку не поминает никто, кроме её собственного модуля.""" + # Область сканирования жива. `rglob` по несуществующему каталогу не падает — + # отдаёт пусто, нарушителей ноль, сторож зелен НАВСЕГДА. Достаточно + # переложить этот файл в подкаталог tests/ (их уже восемь, и прецедент + # такого сторожа лежит именно в подкаталоге), чтобы `parents[1]` уехал. + assert _OWNER.exists(), f"область сканирования съехала: {_APP_DIR}" + + offenders = [ + str(path.relative_to(_BACKEND_ROOT)) + for path in sorted(_APP_DIR.rglob("*.py")) + if path != _OWNER and _references_verify_password(path.read_text(encoding="utf-8")) + ] + + assert offenders == [], ( + f"{offenders}: синхронный verify_password блокирует поток на ~282 мс и НЕ считает " + "слоты. Из кода приложения зови verify_password_bounded (app/core/password.py) — " + "она единственная точка выноса в пул и единственная точка учёта потолка" + ) diff --git a/tradein-mvp/backend/tests/test_payments_notification.py b/tradein-mvp/backend/tests/test_payments_notification.py new file mode 100644 index 00000000..110c8234 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_notification.py @@ -0,0 +1,136 @@ +"""Тесты `app.services.payments.notification` — строгий разбор нотификации. + +`parse_notification()` вызывается ПОСЛЕ `verify_notification_token(...) is True` +(эта функция подпись не проверяет) — тесты здесь работают с payload напрямую, +без пересчёта подписи. +""" + +from __future__ import annotations + +import pytest + +from app.services.payments.notification import ( + NotificationParseError, + TBankNotification, + parse_notification, +) + +_VALID_PAYLOAD = { + "TerminalKey": "1234567890DEMO", + "OrderId": "order-1", + "Success": True, + "Status": "CONFIRMED", + "PaymentId": "0000000", + "Amount": 111100, + "Token": "irrelevant-here", +} + + +# ── happy path ──────────────────────────────────────────────────────────────── + + +def test_parse_notification_happy_path_returns_typed_object() -> None: + result = parse_notification(_VALID_PAYLOAD) + + assert isinstance(result, TBankNotification) + assert result.success is True + assert result.status == "CONFIRMED" + assert result.order_id == "order-1" + assert result.payment_id == "0000000" + assert result.terminal_key == "1234567890DEMO" + assert result.amount_kopecks == 111100 + + +def test_parse_notification_ignores_extra_fields() -> None: + """Лишние поля (ErrorCode, CardId, Pan, ...) в payload не мешают разбору.""" + payload = {**_VALID_PAYLOAD, "ErrorCode": "0", "CardId": "000000", "Pan": "200000******0000"} + result = parse_notification(payload) + assert result.order_id == "order-1" + + +# ── payload не dict ───────────────────────────────────────────────────────── + + +def test_parse_notification_rejects_non_dict_payload() -> None: + with pytest.raises(NotificationParseError): + parse_notification([_VALID_PAYLOAD]) # type: ignore[arg-type] + + +# ── Success: только настоящий bool ────────────────────────────────────────── + + +def test_parse_notification_rejects_success_as_string_true() -> None: + payload = {**_VALID_PAYLOAD, "Success": "true"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_success_as_int_one() -> None: + payload = {**_VALID_PAYLOAD, "Success": 1} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_missing_success() -> None: + payload = {k: v for k, v in _VALID_PAYLOAD.items() if k != "Success"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +# ── Amount: только int, bool отдельно отсекается ──────────────────────────── + + +def test_parse_notification_rejects_amount_as_bool_true() -> None: + """`bool` — подкласс `int` в Python, поэтому отсекается ДО общей int-проверки.""" + payload = {**_VALID_PAYLOAD, "Amount": True} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_bool_false() -> None: + payload = {**_VALID_PAYLOAD, "Amount": False} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_float() -> None: + payload = {**_VALID_PAYLOAD, "Amount": 111100.0} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_string() -> None: + payload = {**_VALID_PAYLOAD, "Amount": "111100"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +# ── строковые поля: только непустой str ───────────────────────────────────── + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_empty_string_field(field: str) -> None: + payload = {**_VALID_PAYLOAD, field: ""} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_non_string_field(field: str) -> None: + payload = {**_VALID_PAYLOAD, field: 12345} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_missing_field(field: str) -> None: + payload = {k: v for k, v in _VALID_PAYLOAD.items() if k != field} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_result_is_frozen() -> None: + """`TBankNotification` — frozen dataclass, случайная мутация после разбора невозможна.""" + result = parse_notification(_VALID_PAYLOAD) + with pytest.raises(AttributeError): + result.amount_kopecks = 1 # type: ignore[misc] diff --git a/tradein-mvp/backend/tests/test_payments_receipt.py b/tradein-mvp/backend/tests/test_payments_receipt.py new file mode 100644 index 00000000..564e6326 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_receipt.py @@ -0,0 +1,208 @@ +"""Тесты `app.services.payments.receipt` — сборка Receipt (ФФД 1.05, услуга).""" + +from __future__ import annotations + +import pytest + +from app.services.payments.receipt import ( + ReceiptBuildError, + ReceiptItem, + build_receipt, + receipt_total_kopecks, +) + + +def _item(price_kopecks: int = 99000, quantity: int = 1, tax: str = "none") -> ReceiptItem: + return ReceiptItem( + name="Отчёт об оценке квартиры (электронный)", + price_kopecks=price_kopecks, + quantity=quantity, + tax=tax, # type: ignore[arg-type] + ) + + +# ── happy path ──────────────────────────────────────────────────────────────── + + +def test_build_receipt_happy_path_with_email() -> None: + receipt = build_receipt(items=[_item()], taxation="usn_income", email="buyer@example.com") + + assert receipt["Taxation"] == "usn_income" + assert receipt["Email"] == "buyer@example.com" + assert "Phone" not in receipt + assert len(receipt["Items"]) == 1 + + item = receipt["Items"][0] + assert item["Name"] == "Отчёт об оценке квартиры (электронный)" + assert item["Price"] == 99000 + assert item["Quantity"] == 1 + assert item["Amount"] == 99000 + assert item["Tax"] == "none" + assert item["PaymentMethod"] == "full_payment" + assert item["PaymentObject"] == "service" + + +def test_build_receipt_happy_path_with_phone_only() -> None: + receipt = build_receipt(items=[_item()], taxation="osn", phone="+79990000000") + assert receipt["Phone"] == "+79990000000" + assert "Email" not in receipt + + +def test_build_receipt_accepts_both_email_and_phone() -> None: + receipt = build_receipt( + items=[_item()], taxation="osn", email="buyer@example.com", phone="+79990000000" + ) + assert receipt["Email"] == "buyer@example.com" + assert receipt["Phone"] == "+79990000000" + + +def test_amount_is_price_times_quantity() -> None: + item = _item(price_kopecks=10000, quantity=3) + assert item.amount_kopecks == 30000 + payload = item.to_payload() + assert payload["Amount"] == 30000 + + +# ── Email/Phone обязательность ────────────────────────────────────────────── + + +def test_build_receipt_requires_email_or_phone() -> None: + with pytest.raises(ReceiptBuildError, match=r"Email.*Phone|Phone.*Email"): + build_receipt(items=[_item()], taxation="osn") + + +def test_build_receipt_rejects_blank_email_and_phone() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt(items=[_item()], taxation="osn", email=" ", phone="") + + +# ── Items[].Name длина ─────────────────────────────────────────────────────── + + +def test_item_name_exactly_128_chars_is_ok() -> None: + item = ReceiptItem(name="A" * 128, price_kopecks=1000) + payload = item.to_payload() + assert payload["Name"] == "A" * 128 + + +def test_item_name_over_128_chars_rejected() -> None: + item = ReceiptItem(name="A" * 129, price_kopecks=1000) + with pytest.raises(ReceiptBuildError, match="128"): + item.to_payload() + + +def test_item_name_empty_rejected() -> None: + item = ReceiptItem(name="", price_kopecks=1000) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +# ── Price / Quantity валидация ────────────────────────────────────────────── + + +def test_item_zero_price_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=0) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +def test_item_negative_price_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=-100) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +def test_item_zero_quantity_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=100, quantity=0) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +# ── Taxation / Tax enum ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("taxation", ["osn", "usn_income", "usn_income_outcome", "esn", "patent"]) +def test_all_documented_taxation_values_accepted(taxation: str) -> None: + build_receipt(items=[_item()], taxation=taxation, email="a@test.ru") # type: ignore[arg-type] + + +def test_unknown_taxation_rejected() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt( + items=[_item()], + taxation="usn", + email="a@test.ru", # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "tax_rate", + ["none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"], +) +def test_all_documented_2026_tax_rates_accepted(tax_rate: str) -> None: + """Актуальный список 2026 года — все значения проходят валидацию.""" + item = _item(tax=tax_rate) + payload = item.to_payload() + assert payload["Tax"] == tax_rate + + +@pytest.mark.parametrize("removed_rate", ["vat20", "vat120"]) +def test_removed_vat20_vat120_rates_rejected(removed_rate: str) -> None: + """vat20/vat120 сняты с актуального списка 2026 — не должны проходить.""" + item = _item(tax=removed_rate) + with pytest.raises(ReceiptBuildError, match="vat20/vat120"): + item.to_payload() + + +# ── Items[] границы ────────────────────────────────────────────────────────── + + +def test_build_receipt_rejects_empty_items() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt(items=[], taxation="osn", email="a@test.ru") + + +def test_build_receipt_rejects_more_than_100_items() -> None: + items = [_item() for _ in range(101)] + with pytest.raises(ReceiptBuildError, match="100"): + build_receipt(items=items, taxation="osn", email="a@test.ru") + + +def test_build_receipt_accepts_exactly_100_items() -> None: + items = [_item() for _ in range(100)] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + assert len(receipt["Items"]) == 100 + + +# ── инвариант: сумма Items[].Amount == сумме заказа ───────────────────────── + + +def test_receipt_total_matches_order_amount_invariant() -> None: + """Ключевой инвариант задачи: сумма Items[].Amount == общей сумме заказа.""" + order_amount_kopecks = 148500 + items = [ + ReceiptItem(name="Отчёт об оценке", price_kopecks=99000, quantity=1, tax="none"), + ReceiptItem(name="Персональный оффер", price_kopecks=49500, quantity=1, tax="none"), + ] + receipt = build_receipt(items=items, taxation="usn_income", email="a@test.ru") + + assert receipt_total_kopecks(receipt) == order_amount_kopecks + + +def test_receipt_total_multi_quantity_item() -> None: + items = [ReceiptItem(name="Оценка", price_kopecks=5000, quantity=4, tax="vat22")] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + assert receipt_total_kopecks(receipt) == 20000 + + +def test_receipt_total_kopecks_empty_items_key_returns_zero() -> None: + assert receipt_total_kopecks({"Taxation": "osn"}) == 0 + + +def test_receipt_total_mismatch_detected_by_caller() -> None: + """Демонстрирует, как вызывающая сторона обязана сверять сумму с Init.Amount.""" + items = [ReceiptItem(name="Оценка", price_kopecks=10000, quantity=1, tax="none")] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + + wrong_init_amount_kopecks = 99999 + assert receipt_total_kopecks(receipt) != wrong_init_amount_kopecks diff --git a/tradein-mvp/backend/tests/test_payments_token.py b/tradein-mvp/backend/tests/test_payments_token.py new file mode 100644 index 00000000..23101559 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_token.py @@ -0,0 +1,256 @@ +"""Тесты `app.services.payments.token` — подпись Token + проверка нотификаций. + +Эталонные векторы (`test_sign_matches_official_init_vector`, +`test_sign_matches_official_notification_vector`) сняты ДОСЛОВНО живым +запросом (curl, 2026-08-06) с официального doc-портала Т-Банка: + +- Init: https://developer.tbank.ru/eacq/intro/developer/token + (раздел «Сформировать токен») +- Нотификация: https://developer.tbank.ru/eacq/intro/developer/notification + (раздел «Проверить токен уведомлений») + +Оба payload'а и оба итоговых hex-digest скопированы из HTML doc-портала +(не выдуманы) — см. промежуточные шаги в комментариях у каждого теста. +Если хеш перестанет сходиться — чинить `token.py`, НЕ тест. +""" + +from __future__ import annotations + +import hashlib + +import pytest + +from app.services.payments.token import TokenSigningError, sign, verify_notification_token + +# ── эталонный вектор №1: Init ──────────────────────────────────────────────── +# Doc-портал, шаг за шагом (см. `token.py` docstring для полного описания): +# 1) [{"TerminalKey": "MerchantTerminalKey"}, {"Amount": 19200}, +# {"OrderId": "00000"}, {"Description": "Подарочная карта на 1000 рублей"}] +# 2) + {"Password": "11111111111111"} +# 3) отсортировано по ключу: Amount, Description, OrderId, Password, TerminalKey +# 4) конкатенация значений: +# "19200Подарочная карта на 1000 рублей0000011111111111111MerchantTerminalKey" +# 5) SHA-256 → "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2" + +_INIT_VECTOR_PAYLOAD = { + "TerminalKey": "MerchantTerminalKey", + "Amount": 19200, + "OrderId": "00000", + "Description": "Подарочная карта на 1000 рублей", +} +_INIT_VECTOR_PASSWORD = "11111111111111" +_INIT_VECTOR_TOKEN = "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2" + + +def test_sign_matches_official_init_vector() -> None: + """Официальный вектор Init из документации Т-Банка.""" + assert sign(_INIT_VECTOR_PAYLOAD, _INIT_VECTOR_PASSWORD) == _INIT_VECTOR_TOKEN + + +# ── эталонный вектор №2: нотификация ───────────────────────────────────────── +# Doc-портал, шаг за шагом: +# 1) [{"TerminalKey": "1234567890DEMO"}, {"OrderId": "000000"}, +# {"Success": true}, {"Status": "AUTHORIZED"}, {"PaymentId": "0000000"}, +# {"ErrorCode": "0"}, {"Amount": 1111}, {"CardId": "000000"}, +# {"Pan": "200000******0000"}, {"ExpDate": "1111"}, {"RebillId": "000000"}] +# 2) + {"Password": "11111111111"} +# 3) отсортировано: Amount, CardId, ErrorCode, ExpDate, OrderId, Pan, +# Password, PaymentId, RebillId, Status, Success, TerminalKey +# 4) конкатенация значений: +# "111100000001111000000200000******0000111111111110000000000000AUTHORIZEDtrue1234567890DEMO" +# 5) SHA-256 → "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8" + +_NOTIFICATION_VECTOR_PAYLOAD = { + "TerminalKey": "1234567890DEMO", + "OrderId": "000000", + "Success": True, + "Status": "AUTHORIZED", + "PaymentId": "0000000", + "ErrorCode": "0", + "Amount": 1111, + "CardId": "000000", + "Pan": "200000******0000", + "ExpDate": "1111", + "RebillId": "000000", +} +_NOTIFICATION_VECTOR_PASSWORD = "11111111111" +_NOTIFICATION_VECTOR_TOKEN = "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8" + + +def test_sign_matches_official_notification_vector() -> None: + """Официальный вектор нотификации (Success/AUTHORIZED) из документации Т-Банка.""" + assert ( + sign(_NOTIFICATION_VECTOR_PAYLOAD, _NOTIFICATION_VECTOR_PASSWORD) + == _NOTIFICATION_VECTOR_TOKEN + ) + + +def test_verify_notification_token_accepts_valid_official_vector() -> None: + """`verify_notification_token` — тот же вектор, но с полем Token внутри payload.""" + payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN} + assert verify_notification_token(payload_with_token, _NOTIFICATION_VECTOR_PASSWORD) is True + + +def test_verify_notification_token_rejects_tampered_field() -> None: + """Изменили Amount после подписи → Token больше не совпадает → False.""" + tampered = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN, "Amount": 9999} + assert verify_notification_token(tampered, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_rejects_wrong_password() -> None: + payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN} + assert verify_notification_token(payload_with_token, "wrong-password") is False + + +def test_verify_notification_token_rejects_missing_token() -> None: + assert verify_notification_token(dict(_NOTIFICATION_VECTOR_PAYLOAD), "any-password") is False + + +def test_verify_notification_token_rejects_empty_token() -> None: + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": ""} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +# ── unit-детали алгоритма ───────────────────────────────────────────────────── + + +def test_bool_true_becomes_lowercase_string() -> None: + """`Success: True` (Python bool) → строка "true" в конкатенации.""" + with_bool = sign({"A": True}, "pw") + with_string = sign({"A": "true"}, "pw") + assert with_bool == with_string + + +def test_bool_false_becomes_lowercase_string() -> None: + with_bool = sign({"A": False}, "pw") + with_string = sign({"A": "false"}, "pw") + assert with_bool == with_string + + +def test_int_amount_stringified_without_quotes_semantics() -> None: + """`Amount: 1111` (int) даёт тот же результат, что и `Amount: "1111"` (str).""" + with_int = sign({"Amount": 1111}, "pw") + with_str = sign({"Amount": "1111"}, "pw") + assert with_int == with_str + + +def test_float_field_raises_instead_of_guessing_format() -> None: + """`float` больше не сериализуется по угадываемому формату — явный отказ. + + Раньше `format(value, "f")` + rstrip нулей выдавал для `0.1 + 0.2` строку + "0.3", а `json.dumps(0.1 + 0.2)` реально даёт "0.30000000000000004" — + подписывалось не то, что уходит в JSON-теле запроса. Для денежного пути + (Amount — всегда int, копейки) правильнее упасть, чем угадать формат. + """ + with pytest.raises(TokenSigningError): + sign({"A": 1234.5}, "pw") + + +def test_large_float_field_also_raises() -> None: + """Тот же явный отказ и для значений, которые раньше ушли бы без экспоненты.""" + with pytest.raises(TokenSigningError): + sign({"A": 1e21}, "pw") + + +def test_amount_plus_float_sum_would_have_diverged_from_json_raises() -> None: + """Закрепляет мотивацию отказа: 0.1+0.2 != json.dumps(0.1+0.2) как строка.""" + computed = 0.1 + 0.2 + assert format(computed, "f").rstrip("0").rstrip(".") == "0.3" + with pytest.raises(TokenSigningError): + sign({"Amount": computed}, "pw") + + +def test_none_values_are_skipped() -> None: + """`None`-поля не участвуют в конкатенации вообще (не как пустая строка).""" + with_none = sign({"A": "x", "B": None}, "pw") + without_key = sign({"A": "x"}, "pw") + assert with_none == without_key + + +def test_nested_receipt_dict_is_ignored() -> None: + """Вложенный `Receipt` (dict) не участвует в подписи.""" + without_receipt = sign({"A": "x"}, "pw") + with_receipt = sign({"A": "x", "Receipt": {"Email": "a@test.ru", "Items": []}}, "pw") + assert without_receipt == with_receipt + + +def test_nested_data_dict_is_ignored() -> None: + """Вложенный `DATA`/`Data` (dict) не участвует в подписи — оба варианта регистра ключа.""" + baseline = sign({"A": "x"}, "pw") + assert sign({"A": "x", "DATA": {"Phone": "+70000000000"}}, "pw") == baseline + assert sign({"A": "x", "Data": {"Phone": "+70000000000"}}, "pw") == baseline + + +def test_nested_list_items_is_ignored() -> None: + """Вложенный список (`Items`/`Shops` как root-ключ) не участвует в подписи.""" + baseline = sign({"A": "x"}, "pw") + assert sign({"A": "x", "Items": [{"Name": "тест"}]}, "pw") == baseline + assert sign({"A": "x", "Shops": [{"ShopCode": "1"}]}, "pw") == baseline + + +def test_existing_token_field_in_payload_is_excluded() -> None: + """Если в payload уже есть `Token` (например, переподписываем нотификацию) — игнорируется.""" + without_token = sign({"A": "x"}, "pw") + with_token = sign({"A": "x", "Token": "stale-value-from-previous-signing"}, "pw") + assert without_token == with_token + + +def test_sort_is_by_key_name_not_insertion_order() -> None: + """Порядок вставки ключей в payload не влияет на результат — сортировка по ключу.""" + forward = sign({"Zeta": "1", "Alpha": "2", "Mid": "3"}, "pw") + reversed_order = sign({"Mid": "3", "Alpha": "2", "Zeta": "1"}, "pw") + assert forward == reversed_order + + # sanity: строка действительно собрана в алфавитном порядке ключей. + # Ключи с Password: Alpha < Mid < Password < Zeta (лексикографически). + raw = "".join(["2", "3", "pw", "1"]) # Alpha->2, Mid->3, Password->pw, Zeta->1 + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert forward == expected + + +# ── verify_notification_token: НИКОГДА не кидает исключение на мусоре ───────── +# +# После появления публичной ручки нотификации (PR-D) необработанное +# исключение здесь = неаутентифицированный HTTP 500 в ответ банку, а любой +# ответ, отличный от "OK", банк трактует как временный сбой и ретраит +# нотификацию почасово в течение суток — см. docstring +# `verify_notification_token`. + + +def test_verify_notification_token_rejects_non_dict_payload_list() -> None: + """payload — список, не dict → `.get()` кинул бы AttributeError без guard'а.""" + assert verify_notification_token([{"Token": "x"}], "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_dict_payload_string() -> None: + assert verify_notification_token("not-a-dict", "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_dict_payload_none() -> None: + assert verify_notification_token(None, "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_ascii_token() -> None: + """Не-ASCII Token → `hmac.compare_digest` кинул бы TypeError без guard'а.""" + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": "кириллица-не-hex-токен"} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_rejects_non_string_token() -> None: + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": 12345} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_does_not_raise_on_float_field() -> None: + """Поле-float где-то в payload (после отказа sign() от float) → False, не исключение.""" + payload = { + "TerminalKey": "demo", + "OrderId": "1", + "Amount": 11.5, # float — sign() теперь явно падает на нём + "Token": "0" * 64, + } + assert verify_notification_token(payload, "pw") is False + + +def test_verify_notification_token_rejects_empty_dict() -> None: + assert verify_notification_token({}, "pw") is False diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index a97b017f..7d6d21ed 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -423,6 +423,86 @@ def test_build_listings_page_none_year_built_no_crash() -> None: assert "РЫНОК КВАРТИР" in html +# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ──────── +# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py +# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list +# (thin listings kept as reference cards) used to print "0 шт." above a +# non-empty examples table — a client-visible contradiction that leaked into +# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_ +# listings_note_html / _reliability_note_html. + + +def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None: + """The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' — + falls back to the actually-shown population (3) and adds an honest + deals-sourced footnote.""" + analogs = [ + _analog(address="ул. Льва Толстого, 8А"), + _analog(address="ул. Кирова, 4"), + _analog(address="ул. Льва Толстого, 34"), + ] + est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "0 шт." not in html + assert "3 шт." in html + assert "Оценка построена по зарегистрированным сделкам Росреестра" in html + assert "почти нет" in html + + +def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None: + """Control: genuinely zero listings (no cards to show either) — '0 шт.' is + honest here, and the deals-sourced footnote (which explains a MISMATCH) + must NOT appear since there is nothing to reconcile.""" + est = _estimate(n_analogs=0, analogs=[]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "0 шт." in html + assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html + + +def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None: + """Control/regression guard for the max() choice: a healthy sample where + n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's + own top-10 cap) must keep printing the full honest count (15 шт.), NOT + silently understate it to len(analogs) (10 шт.).""" + analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)] + est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "15 шт." in html + assert "10 шт." not in html + + +def test_listings_page_relaxations_warning_shown_with_labels() -> None: + """relaxations non-empty → warning block present, names the labels, and + reliability != 'ok' — mirrors what the web LowConfidenceBanner already + shows (see AggregatedEstimate docstring).""" + est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low") + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." in html + assert "учтены студии" in html + assert "радиус расширен до 3000 м" in html + + +def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None: + """reliability != 'ok' but relaxations is empty (cascade couldn't grow a + thin sample, estimator.py #oblast-F) → warning block still shown, with a + fallback sentence (not an empty label list).""" + est = _estimate(n_analogs=2, reliability="very_low", relaxations=[]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." in html + assert "небольшой выборке" in html + + +def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None: + """Control: the common/unrelaxed case (reliability='ok' default, no + relaxations) — no warning block at all, byte-identical to the report + before these fields existed.""" + est = _estimate() + assert est.reliability == "ok" + assert est.relaxations == [] + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." not in html + + def test_build_deals_page_none_year_built_no_crash() -> None: snap = dict(_SNAPSHOT) snap["year_built"] = None @@ -445,3 +525,36 @@ def test_brand_not_taken_from_query_param_docstring() -> None: assert ( "brand" not in param_names ), "estimate_pdf should NOT have a 'brand' query param after #7 fix" + + +# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ── + + +def test_cover_no_retain_until_row_when_unpaid() -> None: + """retain_until IS NULL (default, all current traffic) → no 'Ссылка доступна + до' row at all — B2B regression guard, cover renders bit-for-bit as before.""" + est = _estimate() + assert est.retain_until is None + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" not in html + + +def test_cover_renders_retain_until_row_when_paid() -> None: + """retain_until IS NOT NULL → 'Ссылка доступна до ' row present, with + its OWN date (not conflated with 'Срок действия данных' / expires_at).""" + retain = datetime(2027, 8, 6, tzinfo=UTC) + est = _estimate(retain_until=retain) + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" in html + assert "06.08.2027" in html + + +def test_expires_date_unaffected_by_retain_until() -> None: + """«ДЕЙСТВИТЕЛЕН ДО» (running footer, _expires_date) stays wired to + expires_at regardless of retain_until — it is data-actuality, not the + paid-access retention window, and must not move when a report is paid.""" + expires = datetime.now(UTC) + timedelta(hours=24) + est_unpaid = _estimate(expires_at=expires) + est_paid = _estimate(expires_at=expires, retain_until=expires + timedelta(days=365)) + assert mod._expires_date(est_unpaid) == expires.date() + assert mod._expires_date(est_paid) == expires.date() diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py new file mode 100644 index 00000000..a49f1a96 --- /dev/null +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -0,0 +1,609 @@ +"""ЭТАП 4 B2C launch — retention -> physical deletion task (part B). + +Covers app/tasks/purge_expired_trade_in_data.py: + - batched DELETE (not a single unbounded DELETE), each batch its own commit + - stops draining a table once a batch returns fewer rows than batch_size + - safety cap (max_batches) bounds a single run even on a huge backlog + - both tables (trade_in_estimates, trade_in_leads) get drained + - failure path: rollback + mark_failed with partial counters, exception re-raised + - SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap + - Payments retention (PR #2754): retain_until IS NULL + NOT EXISTS(payments) + safeguards on the estimates DELETE, plus a pre-flight that refuses to run + at all if it finds an ANOMALOUS paid purge-candidate (retain_until unset + despite a payments row) -- a healthy paid row (retain_until set) must NOT + trip it, see test_preflight_ignores_healthy_paid_row below. + +Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched +runs_mod.mark_done/mark_failed). + +Payments retention note on _FakeDB: purge_expired_trade_in_data now issues ONE +extra db.execute() call BEFORE any DELETE batch — the pre-flight paid- +candidates count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that +statement by identity and answers it from `preflight_count` (default 0 == +"no anomalous candidates, proceed exactly as before this PR"). Every +pre-existing test's `db.executed` index shifted by +1 to account for this; +`db.commits` is unaffected (the pre-flight is a read, never committed). +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import purge_expired_trade_in_data as task_mod + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_231 = _SQL_DIR / "231_trade_in_privacy_retention.sql" + + +class _FakeResult: + def __init__(self, rowcount: int) -> None: + self.rowcount = rowcount + + def scalar_one(self) -> int: + """Supports the PR-D1 pre-flight `SELECT count(*) ... .scalar_one()` call.""" + return self.rowcount + + +class _FakeDB: + """Pops rowcounts in call order -- caller supplies the exact sequence expected. + + PR-D1: the pre-flight paid-candidates count is answered separately, from + `preflight_count` (default 0), keyed by statement IDENTITY -- it never + consumes an entry off `rowcounts` (that list is DELETE-batch rowcounts only). + """ + + def __init__(self, rowcounts: list[int], *, preflight_count: int = 0) -> None: + self._rowcounts = list(rowcounts) + self.preflight_count = preflight_count + self.executed: list[tuple[Any, Any]] = [] + self.commits = 0 + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + self.executed.append((stmt, params)) + if stmt is task_mod._PREFLIGHT_PAID_CANDIDATES_SQL: + return _FakeResult(self.preflight_count) + return _FakeResult(self._rowcounts.pop(0)) + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + self.rolled_back = True + + +def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + marked: dict[str, Any] = {} + monkeypatch.setattr( + task_mod.runs_mod, + "mark_done", + lambda _db, run_id, counters: marked.update( + kind="done", run_id=run_id, counters=dict(counters) + ), + ) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda _db, run_id, err, counters: marked.update( + kind="failed", run_id=run_id, err=err, counters=dict(counters) + ), + ) + return marked + + +# ── batching behaviour ────────────────────────────────────────────────────── + + +def test_stops_when_batch_below_size(monkeypatch: pytest.MonkeyPatch) -> None: + marked = _patch_runs(monkeypatch) + # estimates: one batch of 3 (< batch_size=10) -> stop. leads: one batch of 0 -> stop. + db = _FakeDB([3, 0]) + out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 3, "leads_deleted": 0} + # +1 vs pre-PR-D1: db.executed now also holds the pre-flight paid-candidates + # count (call #1), issued before either DELETE batch. + assert len(db.executed) == 3 + assert db.commits == 2 + assert marked["counters"] == out + + +def test_loops_until_below_batch_size(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + # estimates: 5,5,2 (batch_size=5) -> 12 total, 3 batches. leads: 5,1 -> 6 total, 2 batches. + db = _FakeDB([5, 5, 2, 5, 1]) + out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 12, "leads_deleted": 6} + assert len(db.executed) == 6 # +1: pre-flight call before the 5 DELETE batches + assert db.commits == 5, "each batch must commit independently, not one final commit" + + +def test_respects_max_batches_cap(monkeypatch: pytest.MonkeyPatch) -> None: + """Every batch returns a FULL batch_size (never 'caught up') -- only the + max_batches safety cap can stop the loop. Proves the cap is enforced, not + just coincidentally matching a 'caught up' condition.""" + _patch_runs(monkeypatch) + db = _FakeDB([5, 5, 5, 5, 5, 5]) # exactly max_batches=3 per table, no more + out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type] + assert out == {"estimates_deleted": 15, "leads_deleted": 15} + assert len(db.executed) == 7 # pre-flight + 3 (estimates) + 3 (leads), NOT unbounded + + +def test_default_batch_size_and_max_batches_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately + task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type] + # db.executed[0] is now the pre-flight call (no batch_size param) -- the + # first DELETE-batch call (with batch_size) shifted to index 1. + _stmt, params = db.executed[1] + assert params is not None + assert params["batch_size"] == task_mod.settings.trade_in_purge_batch_size + + +# ── table coverage / SQL shape ────────────────────────────────────────────── + + +def test_drains_both_tables_in_order(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) + task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type] + # index 0 is now the pre-flight call; DELETE batches shifted to 1/2. + first_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + second_sql = str(getattr(db.executed[2][0], "text", db.executed[2][0])) + assert "trade_in_estimates" in first_sql + assert "trade_in_leads" in second_sql + + +def test_estimates_sql_is_delete_not_update() -> None: + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "DELETE FROM trade_in_estimates" in sql + assert "UPDATE" not in sql.upper() + assert "expires_at < NOW()" in sql + assert "ORDER BY expires_at" in sql + assert "LIMIT CAST(:batch_size AS int)" in sql + assert not re.search(r":\w+::", sql) + + +# ── Payments retention (PR #2754): two independent purge safeguards ───────── + + +def test_estimates_sql_excludes_retain_until_not_null() -> None: + """Phase 1: exactly `retain_until IS NULL`, never `< NOW()` -- a paid row + (retain_until IS NOT NULL) must never match the DELETE predicate, full stop, + regardless of how far in the past that date eventually sits.""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "retain_until IS NULL" in sql + assert "retain_until <" not in sql, "phase 1 must not weaken to retain_until < NOW()" + + +def test_estimates_sql_has_not_exists_payments_safeguard() -> None: + """Independent safeguard: a row with ANY payments row survives even if + retain_until failed to be set (fulfillment bug/race/manual INSERT).""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "NOT EXISTS" in sql + assert "FROM payments p" in sql + assert "p.estimate_id = trade_in_estimates.id" in sql + + +def test_preflight_sql_requires_retain_until_is_null() -> None: + """Deep-review finding 2026-08-06 MEDIUM (PR #2754): the pre-flight predicate + MUST carry `retain_until IS NULL` -- without it, a perfectly healthy paid row + (retain_until set, has a payments row -- the ORDINARY steady state one day + after every sale) trips the alarm exactly as hard as the real anomaly + (retain_until unset despite a payments row existing), permanently wedging + the job on the very first successful sale (and, since leads purge runs from + the same function AFTER this check, silently stopping 180-day 152-ФЗ lead + retention too). See test_real_preflight_ignores_healthy_paid_row below for + the behavioural proof against a real DB.""" + sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text + assert "expires_at < NOW()" in sql + assert "created_by IS NULL" in sql + assert "retain_until IS NULL" in sql + assert "EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)" in sql + assert not re.search(r":\w+::", sql) + + +def test_preflight_aborts_before_any_delete_batch(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-zero pre-flight count -> RuntimeError, mark_failed, ZERO DELETE batches + ever issued (only the pre-flight SELECT itself is in db.executed).""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([], preflight_count=2) # rowcounts empty on purpose: must never be popped + with pytest.raises(RuntimeError, match="pre-flight abort"): + task_mod.purge_expired_trade_in_data(db, run_id=42, batch_size=10, max_batches=20) # type: ignore[arg-type] + + assert len(db.executed) == 1, "only the pre-flight SELECT -- no DELETE batch was issued" + assert db.commits == 0 + assert marked["kind"] == "failed" + assert marked["run_id"] == 42 + assert marked["counters"] == {"estimates_deleted": 0, "leads_deleted": 0} + assert "2" in marked["err"] + + +def test_preflight_zero_candidates_proceeds_as_before(monkeypatch: pytest.MonkeyPatch) -> None: + """preflight_count=0 (default) -- the exact pre-PR-D1 behaviour for every + row that exists today (all retain_until IS NULL) -- run proceeds normally.""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) # preflight_count defaults to 0 + out = task_mod.purge_expired_trade_in_data(db, run_id=43, batch_size=10, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 0, "leads_deleted": 0} + assert marked["kind"] == "done" + + +def test_leads_sql_unchanged_by_pr_d1() -> None: + """Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by payments + retention (PR #2754) — leads have their own retention deadline (migration + 231, no created_by/B2B split, no payments concept) and are explicitly out + of scope for the payments-retention safeguards.""" + expected = ( + "\n DELETE FROM trade_in_leads\n WHERE id IN (\n" + " SELECT id FROM trade_in_leads\n" + " WHERE expires_at < NOW()\n" + " ORDER BY expires_at\n" + " LIMIT CAST(:batch_size AS int)\n )\n " + ) + assert task_mod._DELETE_EXPIRED_LEADS_SQL.text == expected + + +def test_leads_sql_is_delete_not_update() -> None: + sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text + assert "DELETE FROM trade_in_leads" in sql + assert "UPDATE" not in sql.upper() + assert "expires_at < NOW()" in sql + assert "ORDER BY expires_at" in sql + assert not re.search(r":\w+::", sql) + + +def test_estimates_sql_scopes_delete_to_anonymous_created_by_null() -> None: + """Regression guard for the deep-review HIGH finding (2026-08-06): + trade_in_estimates.expires_at is set UNCONDITIONALLY on every estimate + (B2B pilot or anonymous) as a TTL on the estimate LINK/PDF staying + resolvable, NOT a declared row-retention deadline -- see the task's + module docstring. Without this guard, prod audit showed 1040/1057 rows + past expires_at, 911 of them belonging to named pilots (admin, kopylov, + brusnika, praktika, pilottest, admintest, user1); one unattended run at + the seeded defaults (batch_size=500, max_batches=20) would have deleted + essentially the whole table, including pilots' own operational history + (/trade-in/history, /team/employees/{id}/history, cache-stats all read + trade_in_estimates without an expires_at filter). The DELETE must stay + scoped to created_by IS NULL -- the honest B2C population (129 rows in + that same audit).""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "created_by IS NULL" in sql + assert not re.search(r":\w+::", sql) + + +def test_leads_sql_has_no_created_by_guard() -> None: + """trade_in_leads has NO created_by column at all (never had a B2B/B2C + split) -- its expires_at IS a genuine 180-day retention deadline (see + migration 231), not a link/PDF-access TTL like trade_in_estimates. This + documents the asymmetry explicitly so a future 'fix' doesn't bolt a + created_by filter onto a table that doesn't have the column.""" + sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text + assert "created_by" not in sql + + +def test_sql_does_not_delete_whole_table_unbounded() -> None: + """Neither statement is a bare `DELETE FROM table` -- both scope via a + subselect + LIMIT batch.""" + statements = ( + task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text, + task_mod._DELETE_EXPIRED_LEADS_SQL.text, + ) + for sql in statements: + assert "WHERE id IN (" in sql + assert "LIMIT" in sql + + +# ── failure path ───────────────────────────────────────────────────────────── + + +def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: + marked = _patch_runs(monkeypatch) + + class _BoomDB(_FakeDB): + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + # Call #1 (pre-flight, preflight_count=0) and call #2 (first estimates + # batch) succeed and commit; call #3 (still draining estimates, or + # first leads call) explodes. +1 vs pre-PR-D1 to admit the pre-flight. + if len(self.executed) >= 2: + raise RuntimeError("db exploded") + return super().execute(stmt, params) + + db = _BoomDB([5]) # only ONE successful DELETE batch before the boom + with pytest.raises(RuntimeError, match="db exploded"): + task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type] + + assert db.rolled_back is True + assert marked["kind"] == "failed" + assert marked["run_id"] == 6 + # Partial progress preserved in the counters passed to mark_failed (first + # estimates batch of 5 already committed before the boom on the 2nd call). + assert marked["counters"]["estimates_deleted"] == 5 + assert marked["counters"]["leads_deleted"] == 0 + + +def test_idempotent_zero_rowcount_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Re-running against an already-drained backlog just deletes 0 rows, cleanly.""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) + out = task_mod.purge_expired_trade_in_data(db, run_id=7, batch_size=500, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 0, "leads_deleted": 0} + assert marked["kind"] == "done" + + +# ── migration 231 ──────────────────────────────────────────────────────────── + + +def test_migration_231_exists() -> None: + assert _MIGRATION_231.is_file(), f"missing migration: {_MIGRATION_231}" + + +def test_migration_231_is_transactional() -> None: + sql = _MIGRATION_231.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_231_backfills_and_sets_not_null() -> None: + sql = _MIGRATION_231.read_text("utf-8") + assert "ADD COLUMN IF NOT EXISTS expires_at" in sql + assert "WHERE expires_at IS NULL" in sql + assert "SET NOT NULL" in sql + assert "180 days" in sql + + +def test_migration_231_seeds_purge_schedule_disabled_by_default() -> None: + sql = _MIGRATION_231.read_text("utf-8") + assert "'purge_expired_trade_in_data'" in sql + assert "ON CONFLICT (source) DO NOTHING" in sql + # Seeded disabled -- first automated PII-DELETE job in trade-in deserves a + # supervised first run before the scheduler can trigger it unattended. + assert re.search(r"'purge_expired_trade_in_data',\s*\n\s*false,", sql) + + +def test_migration_231_no_psycopg_trap() -> None: + sql = _MIGRATION_231.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +# ── Optional real-Postgres behavioural test (self-skips without a DB) ────────── +# Same pattern as tests/test_house_dedup_merge.py::_live_session -- CI runs the +# mock-only lane (DATABASE_URL is a placeholder), so this self-skips there; it +# only executes with a real reachable Postgres (e.g. local dev DB). + + +def _live_session() -> Any | None: + """Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None.""" + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + from sqlalchemy import text as _t + + conn.execute(_t("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_purge_deletes_only_anonymous_expired_estimates() -> None: + """End-to-end on a real DB: one expired B2B row (created_by set) and one + expired anonymous row (created_by IS NULL) both exist -- a real run of + purge_expired_trade_in_data must delete ONLY the anonymous one. This is + the exact scenario the deep-review HIGH finding (2026-08-06) flagged: + without the created_by IS NULL guard, the pilot row would also be gone.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + anon_id = uuid4() + pilot_id = uuid4() + try: + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by) VALUES " + "(CAST(:anon_id AS uuid), 'purge-test аноним', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL), " + "(CAST(:pilot_id AS uuid), 'purge-test пилот', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', 'pytest_purge_guard')" + ), + {"anon_id": str(anon_id), "pilot_id": str(pilot_id)}, + ) + db.commit() + + task_mod.purge_expired_trade_in_data(db, run_id=999999999, batch_size=100, max_batches=5) + + remaining_ids = { + str(r) + for r in db.execute( + _t("SELECT id FROM trade_in_estimates " "WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(anon_id), str(pilot_id)]}, + ) + .scalars() + .all() + } + assert str(anon_id) not in remaining_ids, "anonymous expired row must be purged" + assert str(pilot_id) in remaining_ids, "B2B pilot row must survive despite expires_at" + finally: + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(anon_id), str(pilot_id)]}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754, reproduced exactly + against a real DB: a HEALTHY paid row (retain_until set, a payments row + exists) is the ordinary steady state one day after every sale and must NOT + raise the pre-flight count; a row where fulfillment failed to set + retain_until despite a payments row existing is the real ANOMALY and must. + Baseline-delta assertions (not absolute counts) so this is safe to run + against a dev DB that may already contain unrelated rows.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + anomaly_id = uuid4() + healthy_order = f"pytest-healthy-{uuid4().hex[:12]}" + anomaly_order = f"pytest-anomaly-{uuid4().hex[:12]}" + try: + baseline = task_mod._preflight_paid_candidates(db) + + # Healthy: retain_until set (paid, safeguard 1 already protects it) + + # a payments row -- exactly what every successful sale looks like a day + # later. Must NOT move the pre-flight count. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test здоровая оплаченная', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + assert ( + task_mod._preflight_paid_candidates(db) == baseline + ), "healthy paid row (retain_until set) must NOT raise the pre-flight count" + + # Anomaly: retain_until NULL despite a payments row existing -- exactly + # the case the two DELETE safeguards exist for. Must raise by exactly one. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test настоящая аномалия', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NULL)" + ), + {"id": str(anomaly_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": anomaly_order, "id": str(anomaly_id)}, + ) + db.commit() + assert task_mod._preflight_paid_candidates(db) == baseline + 1, ( + "anomaly row (retain_until unset, payments row exists) must raise " + "the pre-flight count by exactly one" + ) + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = ANY(CAST(:orders AS text[]))"), + {"orders": [healthy_order, anomaly_order]}, + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(healthy_id), str(anomaly_id)]}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_purge_not_wedged_by_healthy_paid_row() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754: before the fix, a + healthy paid row anywhere in the table (retain_until set, has a payments + row) permanently wedged the job -- the very first successful sale would + have made every subsequent scheduled run abort in mark_failed with zero + deletions FOREVER, silently taking 180-day leads purge (152-ФЗ) down with + it (leads purge runs from the same function AFTER the pre-flight check). + This proves a real end-to-end run completes normally (mark_done) in the + presence of such a row.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + healthy_order = f"pytest-wedge-{uuid4().hex[:12]}" + try: + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test не блокирует джобу', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + + # Must complete normally -- no RuntimeError, no mark_failed short-circuit + # (would raise before reaching this line if the bug were still present). + result = task_mod.purge_expired_trade_in_data( + db, run_id=999999998, batch_size=100, max_batches=1 + ) + assert set(result) == {"estimates_deleted", "leads_deleted"}, ( + "leads purge must also have run -- it is NOT reachable when the " + "pre-flight wrongly aborts first" + ) + + still_there = db.execute( + _t("SELECT id FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ).fetchone() + assert still_there is not None, "healthy paid row must survive the run untouched" + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = :order_id"), {"order_id": healthy_order} + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ) + db.commit() + db.close() diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 184c785d..e0af347b 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -8,6 +8,8 @@ Covers: - linkage_rate_pct computation. - median_discount_pct on subset с listing_id != None. - extract_street_name failure → returns empty response with street=None. + - #2666 гейт правдоподобия median_discount_pct: мало пар / значение вне + санитарного диапазона → числа нет, но есть median_discount_explanation. """ import os @@ -101,6 +103,26 @@ def _make_pair_row( } +def _rows_with_discounts(discounts: list[float]) -> list[dict]: + """N пар с заданными discount_pct (deal_id/listing_id уникальны).""" + return [ + _make_pair_row(deal_id=1000 + i, listing_id=2000 + i, discount_pct=d) + for i, d in enumerate(discounts) + ] + + +def _rows_on_shared_listings(discounts: list[float], n_listings: int) -> list[dict]: + """N пар, разложенных по n_listings РАЗЛИЧНЫМ объявлениям (#2672). + + Псевдореплики из прода: сделки уникальны, а listing_id переиспользуется — + ровно то, что `_rows_with_discounts` (одно объявление на пару) не выражает. + """ + return [ + _make_pair_row(deal_id=1000 + i, listing_id=2000 + (i % n_listings), discount_pct=d) + for i, d in enumerate(discounts) + ] + + def _override_db(trade_in_app: FastAPI, db_mock: MagicMock) -> None: from app.core.db import get_db @@ -198,14 +220,22 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 1 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 100.0 - assert data["median_discount_pct"] == -5.77 + # #2666: сводная медиана по одной паре гасится гейтом правдоподобия. + assert data["median_discount_pct"] is None + # «всего 1 —» целиком: голое "1" было бы всегда истинно (подстрока "10"). + assert "всего 1 —" in data["median_discount_explanation"] assert len(data["pairs"]) == 1 pair = data["pairs"][0] assert pair["deal_id"] == 1001 assert pair["listing_id"] == 2001 assert pair["listing_source"] == "avito" assert pair["days_listing_to_deal"] == 46 - assert pair["discount_pct"] == -5.77 + # #2672 п.3 (было: «пара — наблюдаемый факт, отдаём как есть»): эта пара стоит + # на ОДНОМ объявлении, поэтому её процент тоже гаснет. Обе цены — сделки и + # объявления — остаются: убирается подпись «торг», а не данные. + assert pair["discount_pct"] is None + assert pair["deal_price_rub"] == 4_900_000 + assert pair["listing_price_rub"] == 5_200_000 # ── Test: LEFT JOIN — deal без listing match ───────────────────────────────── @@ -251,8 +281,9 @@ def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 2 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 50.0 - # median считается только по парам с discount_pct - assert data["median_discount_pct"] == -5.0 + # median считается только по парам с discount_pct — но одной пары мало, + # #2666 гейт её гасит (сам LEFT JOIN это не ломает). + assert data["median_discount_pct"] is None # Pair without listing pair_no_listing = next(p for p in data["pairs"] if p["deal_id"] == 1002) assert pair_no_listing["listing_id"] is None @@ -265,14 +296,16 @@ def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: - """Median считается через _percentile(0.5) только по парам c discount_pct.""" - # Discounts: [-10, -5, 0, 3, 7] → median = 0 + """Median считается через _percentile(0.5) только по парам c discount_pct. + + 11 пар (≥ MIN_PAIRS #2666) с рыночной медианой — число доходит до ответа, + объяснения нет. + """ + # Discounts: 11 значений, средний (индекс 5) = -17.0 → median = -17.0 fixture_rows = [ - _make_pair_row(deal_id=1, listing_id=11, discount_pct=-10.0), - _make_pair_row(deal_id=2, listing_id=12, discount_pct=-5.0), - _make_pair_row(deal_id=3, listing_id=13, discount_pct=0.0), - _make_pair_row(deal_id=4, listing_id=14, discount_pct=3.0), - _make_pair_row(deal_id=5, listing_id=15, discount_pct=7.0), + *_rows_with_discounts( + [-25.0, -23.0, -21.0, -20.0, -19.0, -17.0, -16.0, -15.0, -13.0, -11.0, -9.0] + ), # Сделка без listing — не учитывается в median. _make_pair_row( deal_id=6, @@ -295,10 +328,11 @@ def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: ) assert resp.status_code == 200 data = resp.json() - assert data["total_deals"] == 6 - assert data["deals_with_listings"] == 5 - assert round(data["linkage_rate_pct"], 1) == 83.3 - assert data["median_discount_pct"] == 0.0 + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 11 + assert round(data["linkage_rate_pct"], 1) == 91.7 + assert data["median_discount_pct"] == -17.0 + assert data["median_discount_explanation"] is None # ── Test: SQL function called with proper params ───────────────────────────── @@ -417,6 +451,7 @@ def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None: "deals_with_listings", "linkage_rate_pct", "median_discount_pct", + "median_discount_explanation", "pairs", } assert expected_keys.issubset(data.keys()) @@ -506,3 +541,271 @@ def test_sales_vs_listings_defaults(trade_in_app: FastAPI) -> None: assert data["window_days"] == 180 assert data["area_tolerance"] == 0.15 assert data["period_months"] == 24 + + +# ── Test: #2666 гейт правдоподобия median_discount_pct ─────────────────────── + + +def _get_sales(trade_in_app: FastAPI, rows: list[dict]) -> dict: + """GET /sales-vs-listings на фиксированном адресе, вернуть JSON.""" + _override_db(trade_in_app, _make_db_mock(rows)) + resp = TestClient(trade_in_app).get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "г. Екатеринбург, ул. Космонавтов, 50", + "area_m2": 50.0, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + return resp.json() + + +def test_median_discount_gated_when_too_few_pairs(trade_in_app: FastAPI) -> None: + """#2666: пар меньше MIN_PAIRS → числа нет, но есть объяснение почему. + + Прод-бутстрап (2026-08-05): на 9 парах p90 отклонения медианы подвыборки от + полной ≈ 12-19 п.п. — такое число нельзя показывать как «медианный торг». + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 9)) + assert data["deals_with_listings"] == 9 + assert data["median_discount_pct"] is None + # Объяснение называет ФАКТИЧЕСКОЕ число пар — иначе оно бесполезно. И НЕ + # обещает надёжность выше порога: 10 пар тоже не гарантия (ревью #2671). + assert "всего 9 —" in data["median_discount_explanation"] + assert "надёжн" not in data["median_discount_explanation"] + + +def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> None: + """MIN_PAIRS включительно: ровно 10 пар — число ещё отдаётся.""" + data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 10)) + assert data["median_discount_pct"] == -12.0 + assert data["median_discount_explanation"] is None + + +def test_median_discount_gated_when_all_pairs_share_one_listing(trade_in_app: FastAPI) -> None: + """#2672: 30 пар, но ОДНО различное объявление → числа нет. + + Живой кейс (`Академика Ландау` 1-комн., −12.36%) выбран намеренно «скучным»: + пар втрое больше порога, значение рыночное — ни один из старых признаков не + срабатывает, гасит ровно новый. 30 сделок, делённые на одну цену предложения, + это не 30 наблюдений рынка, а одно. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.36] * 30, n_listings=1)) + assert data["deals_with_listings"] == 30 + assert data["median_discount_pct"] is None + # Объяснение обязано назвать ОБА числа — «сделок 30» без «объявлений 1» + # читается как «данных достаточно», т.е. ровно наоборот. + assert "сделок 30" in data["median_discount_explanation"] + assert "всего 1" in data["median_discount_explanation"] + + +def test_median_discount_one_listing_gated_before_range(trade_in_app: FastAPI) -> None: + """#2672: `Белинского` 1-комн. — 50 пар, 1 объявление, −50.6%. + + Проходил старый гейт целиком (пар с запасом, −50.6% внутри старых [−60,+20]). + Сработать могли бы обе новые проверки; порядок закреплён: причина — «одно + объявление», а не «неправдоподобное значение». Она первична: пока выборка + стоит на одной цене, разбирать правдоподобность результата нечего. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-50.64] * 50, n_listings=1)) + assert data["median_discount_pct"] is None + assert "всего 1" in data["median_discount_explanation"] + assert "неправдоподобное" not in data["median_discount_explanation"] + + +def test_median_discount_kosmonavtov_117_pairs_two_listings(trade_in_app: FastAPI) -> None: + """#2672 кейс, ради которого всё затевалось: 117 пар → 2 различных объявления. + + Порог по объявлениям здесь НЕ срабатывает (2 ≥ 2, граница включительная) — + гасит ужесточённая нижняя граница: −37.6% глубже −35% и в полтора раза глубже + худшего объяснимого рынком бакета. Старый код показывал это число. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-37.57] * 117, n_listings=2)) + assert data["deals_with_listings"] == 117 + assert data["median_discount_pct"] is None + assert "неправдоподобное" in data["median_discount_explanation"] + assert "всего 2" not in data["median_discount_explanation"] + + +def test_median_discount_kept_at_min_distinct_listings_boundary(trade_in_app: FastAPI) -> None: + """Граница MIN_DISTINCT_LISTINGS включительная: 2 объявления — число отдаётся. + + Сторожит противоположное направление: гейт считает ОБЪЯВЛЕНИЯ, но не должен + съесть всё, что не идеально. 117 пар на 2 объявлениях с рыночной медианой + (вторая группа Космонавтова, −12.98%) проходят. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.98] * 117, n_listings=2)) + assert data["median_discount_pct"] == -12.98 + assert data["median_discount_explanation"] is None + + +def test_row_discounts_gated_when_pairs_stand_on_one_listing(trade_in_app: FastAPI) -> None: + """#2672 п.3: погашена медиана — гаснут и проценты в строках таблицы. + + 50 пар на ОДНОМ объявлении: столбец «разница» здесь — столбец цены сделки, + поделённый на одну и ту же константу, т.е. ни одного наблюдения сверх уже + показанных цен. Старый код отдавал 50 процентов под погашенным сводным + числом. Цены сделки и объявления остаются — убирается подпись «торг». + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.36] * 50, n_listings=1)) + assert data["median_discount_pct"] is None + assert all(p["discount_pct"] is None for p in data["pairs"]) + assert all(p["listing_price_rub"] is not None for p in data["pairs"]) + # Микрокопия обязана назвать причину строк, а не только медианы. + assert "по каждой сделке тоже не показываем" in data["median_discount_explanation"] + assert "одной и той же цены" in data["median_discount_explanation"] + + +def test_row_discounts_gated_when_median_implausible(trade_in_app: FastAPI) -> None: + """#2672 п.3, живой кейс: под погашенной медианой Космонавтова видны +76%. + + 7 пар по −37.57% и 4 по +76% на двух объявлениях: порог по объявлениям не + срабатывает (2 ≥ 2), медиана −37.6% вне диапазона. По определению медианы + это утверждение о СТРОКАХ — половина из них ещё дальше от рынка. + """ + rows = _rows_on_shared_listings([-37.57] * 7 + [76.0] * 4, n_listings=2) + data = _get_sales(trade_in_app, rows) + assert data["median_discount_pct"] is None + assert all(p["discount_pct"] is None for p in data["pairs"]) + assert "половина из них" in data["median_discount_explanation"] + # Значение медианы в объяснении строк НЕ повторяется: в этой ветке оно уже + # названо предложением выше, а в ветке «мало пар» его намеренно не + # показывают — там оно стало бы отказом на словах. + assert data["median_discount_explanation"].count("−37.6%") == 1 + + +def test_row_discounts_kept_when_gate_fired_only_on_sample_size(trade_in_app: FastAPI) -> None: + """«Мало пар» гасит медиану, но НЕ строки — это свойство выборки, не пары. + + 4 пары на 4 объявлениях с рыночной медианой: про отдельную пару «пар всего + 4» не говорит ничего, и микрокопия «поэтому процент в строке не показываем» + была бы ложной причиной. На проде это 3 группы / 20 строк. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-8.0, -9.0, -10.0, -11.0])) + assert data["median_discount_pct"] is None + assert "пар «сделка ↔ объявление» всего 4" in data["median_discount_explanation"] + assert "по каждой сделке тоже не показываем" not in data["median_discount_explanation"] + assert [p["discount_pct"] for p in data["pairs"]] == [-8.0, -9.0, -10.0, -11.0] + + +def test_row_discounts_kept_when_median_shown(trade_in_app: FastAPI) -> None: + """Обратное направление: медиана прошла гейт — строки не трогаем.""" + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.98] * 117, n_listings=2)) + assert data["median_discount_pct"] == -12.98 + assert all(p["discount_pct"] == -12.98 for p in data["pairs"]) + + +def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) -> None: + """#2666: «продали на 36% дороже, чем просили» — артефакт пейринга по улице. + + Ровно кейс из issue (`%Космонавтов%` 2-комн., +36.4% после гарда #2660). + Пар достаточно, гасит именно санитарный диапазон. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) + assert data["deals_with_listings"] == 11 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + assert "36" in data["median_discount_explanation"] + + +def test_median_discount_gated_when_implausibly_negative(trade_in_app: FastAPI) -> None: + """Нижняя граница диапазона: −70% в 3 раза глубже худшего городского + asking→sold бакета (студии, ratio 0.7634 = −23.7%) — тоже не рынок.""" + data = _get_sales(trade_in_app, _rows_with_discounts([-70.0] * 11)) + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + + +def test_median_discount_gated_below_tightened_floor(trade_in_app: FastAPI) -> None: + """#2672: −46.5% гасится, хотя старую границу −60% проходил. + + Живой кейс из ревью #2671: Серов, ул. Ленина 163, 2-комн., 21 пара — + показывался «медианный торг −46.54%» при том, что худший объяснимый рынком + бакет = −23.7%. Пар вдвое больше порога, объявлений хватает — гасит именно + ужесточённая нижняя граница, и только она. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-46.54] * 21)) + assert data["median_discount_pct"] is None + assert "неправдоподобное" in data["median_discount_explanation"] + assert "46" in data["median_discount_explanation"] + + +def test_median_discount_kept_at_sane_range_boundaries(trade_in_app: FastAPI) -> None: + """Границы санитарного диапазона включительные: +20.0% и −35.0% проходят.""" + top = _get_sales(trade_in_app, _rows_with_discounts([20.0] * 11)) + assert top["median_discount_pct"] == 20.0 + assert top["median_discount_explanation"] is None + + bottom = _get_sales(trade_in_app, _rows_with_discounts([-35.0] * 11)) + assert bottom["median_discount_pct"] == -35.0 + assert bottom["median_discount_explanation"] is None + + +def test_median_discount_normal_case_unchanged(trade_in_app: FastAPI) -> None: + """Нормальный случай (пар хватает, значение рыночное) — число как прежде.""" + data = _get_sales( + trade_in_app, + _rows_with_discounts( + [-25.0, -23.0, -21.0, -20.0, -19.0, -17.0, -16.0, -15.0, -13.0, -11.0, -9.0] + ), + ) + assert data["median_discount_pct"] == -17.0 + assert data["median_discount_explanation"] is None + + +def test_median_discount_explanation_absent_when_no_pairs_at_all( + trade_in_app: FastAPI, +) -> None: + """Сделки есть, но ни одной пары → медианы просто НЕТ, объяснять нечего. + + Отличать «не посчиталось» от «посчиталось и погашено гейтом» обязан фронт: + он рендерит объяснение вместо числа, и текст «медиана гуляет» на улице без + единого объявления был бы враньём. + """ + rows = [ + _make_pair_row( + deal_id=1000 + i, + listing_id=None, + listing_price_rub=None, + discount_pct=None, + ) + for i in range(12) + ] + data = _get_sales(trade_in_app, rows) + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 0 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] is None + + +def test_too_few_pairs_reported_before_out_of_range(trade_in_app: FastAPI) -> None: + """Порядок проверок: 3 пары по +80% — причина «мало пар», НЕ «вне диапазона». + + Обе проверки сработали бы, но «пар всего 3» информативнее и точнее: при + такой выборке значение вообще не заслуживает разбора на правдоподобность. + Тест закрепляет порядок — перестановка условий деградирует объяснение. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([80.0] * 3)) + assert data["median_discount_pct"] is None + assert "всего 3 —" in data["median_discount_explanation"] + assert "неправдоподобное" not in data["median_discount_explanation"] + + +def test_median_discount_gate_leaves_pairs_and_linkage_untouched( + trade_in_app: FastAPI, +) -> None: + """Гейт гасит ЧИСЛА, но не виджет: сами пары, цены и linkage остаются. + + #2672 п.3 сузил это правило: 11 пар с медианой +36.4% вне диапазона теряют и + поштучный процент (утверждение о медиане здесь же и утверждение о строках — + половина из них ещё дальше от рынка). Строка при этом остаётся полной: цена + сделки, цена объявления, срок и ссылка на месте. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) + assert data["median_discount_pct"] is None + assert data["linkage_rate_pct"] == 100.0 + assert len(data["pairs"]) == 11 + assert all(p["discount_pct"] is None for p in data["pairs"]) + assert all(p["listing_price_rub"] is not None for p in data["pairs"]) + assert all(p["listing_source_url"] for p in data["pairs"]) diff --git a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py index 114322af..726d991e 100644 --- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py +++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py @@ -28,6 +28,7 @@ from app.tasks import sber_freshness_monitor as mon _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) @@ -218,6 +219,40 @@ def test_migration_180_no_psycopg_trap() -> None: assert not re.search(r":\w+::", sql) +# ── Миграция 212: такт загрузки не должен пересекать порог монитора ─────────── +# +# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74 +# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника. +# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора. + + +def test_migration_212_makes_pull_cadence_weekly() -> None: + sql = _MIGRATION_212.read_text("utf-8") + assert "sber_index_pull" in sql + assert '"interval_days": 7' in sql + assert "BEGIN;" in sql and "COMMIT;" in sql + 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 — тревога каждый цикл, что и наблюдали. + """ + 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}д — " + "монитор снова будет мерить наш такт, а не застой источника" + ) + + # ── Регистрация в kit registry ───────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_sber_index.py b/tradein-mvp/backend/tests/test_sber_index.py index ed31e11c..61b7f1c7 100644 --- a/tradein-mvp/backend/tests/test_sber_index.py +++ b/tradein-mvp/backend/tests/test_sber_index.py @@ -469,10 +469,15 @@ async def test_pull_sber_indices_error_per_series_continues() -> None: @pytest.mark.asyncio -async def test_pull_sber_indices_404_logs_warning_with_path_hint( +async def test_pull_sber_indices_404_logs_error_with_path_hint( caplog: pytest.LogCaptureFixture, ) -> None: - """#902: a 404 logs a WARNING naming the slug + 'dataset-path invalid?' hint.""" + """#902: a 404 logs the slug + 'dataset-path invalid?' hint. + + #2674: уровень поднят WARNING → ERROR. 404 = переименованный slug, самая + ПЕРМАНЕНТНАЯ из трёх веток отказа (5xx и сетевой сбой проходят сами), а до этого + она была тише соседних и событием GlitchTip не становилась. + """ import logging import httpx @@ -498,11 +503,11 @@ async def test_pull_sber_indices_404_logs_warning_with_path_hint( ) assert result["errors"] == 1 - warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + errors = [r for r in caplog.records if r.levelno >= logging.ERROR] assert any( "residential_real_estate_prices" in r.message and "dataset-path invalid" in r.message - for r in warnings - ), f"Expected a 404 dataset-path warning, got: {[r.message for r in warnings]}" + for r in errors + ), f"Expected a 404 dataset-path ERROR, got: {[r.message for r in errors]}" @pytest.mark.asyncio diff --git a/tradein-mvp/backend/tests/test_schedule_save_respects_interval_2674.py b/tradein-mvp/backend/tests/test_schedule_save_respects_interval_2674.py new file mode 100644 index 00000000..a9bfddba --- /dev/null +++ b/tradein-mvp/backend/tests/test_schedule_save_respects_interval_2674.py @@ -0,0 +1,171 @@ +"""Сохранение расписания в админке уважает такт источника (#2674). + +Баг: PUT /admin/scrape/schedules/{source} звал compute_next_run_at БЕЗ interval_days, +получал default=1 и ставил next_run_at на завтра — какой бы такт ни стоял в +default_params. Недельный avito_full_load после правки соседнего поля побежал бы через +сутки. На суточных источниках дефект невидим: для них «завтра» и есть верный ответ, +поэтому баг прожил до разбора #2674. + +Планировщик (scraper_kit.orchestration.scheduler._claim_run/_defer_next_run_at) такт +читал правильно — расходились именно два входа в одну и ту же формулу. + +Без сети, без БД: endpoint вызывается напрямую с mock-сессией, проверяются bind-params. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.api.v1.admin import update_schedule +from app.schemas.trade_in import ScheduleConfigUpdate + + +def _mock_db() -> MagicMock: + db = MagicMock() + row = { + "id": 1, + "source": "avito_full_load", + "enabled": True, + "window_start_hour": 13, + "window_end_hour": 15, + "default_params": {}, + "last_run_id": None, + "last_run_at": None, + "next_run_at": None, + "updated_at": None, + } + db.execute.return_value.mappings.return_value.fetchone.return_value = row + return db + + +def _saved_params(db: MagicMock) -> dict[str, Any]: + """Bind-params единственного execute в update_schedule.""" + return db.execute.call_args_list[0].args[1] + + +def _saved_sql(db: MagicMock) -> str: + return str(db.execute.call_args_list[0].args[0]) + + +def test_weekly_source_gets_next_run_in_a_week_not_tomorrow() -> None: + """Такт 7 → сохранение → next_run_at примерно через 7 суток. Падает на старом коде.""" + db = _mock_db() + before = datetime.now(tz=UTC) + + update_schedule( + "avito_full_load", + ScheduleConfigUpdate( + enabled=True, + window_start_hour=13, + window_end_hour=15, + default_params={"interval_days": 7, "concurrency": 1}, + ), + db, + ) + + next_at = _saved_params(db)["next_at"] + delta_days = (next_at - before).total_seconds() / 86400 + # Окно 13:00-15:00 внутри целевых суток → разброс ±1 сутки вокруг ровно 7. + assert 6.0 < delta_days < 8.0, f"ожидали ~7 суток, получили {delta_days:.2f}" + # Старое поведение (interval_days не передан → default=1) дало бы «завтра». + assert delta_days > 2.0, "next_run_at уехал на завтра — такт снова потерян" + + +def test_daily_source_still_runs_tomorrow() -> None: + """Такт 1 (и его отсутствие) — прежнее поведение, back-compat.""" + for params in ({}, {"interval_days": 1}): + db = _mock_db() + before = datetime.now(tz=UTC) + update_schedule( + "cian_city_sweep", + ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params=params), + db, + ) + delta_days = (_saved_params(db)["next_at"] - before).total_seconds() / 86400 + assert 0.0 < delta_days < 2.0, f"params={params}: ожидали «завтра», got {delta_days:.2f}" + + +def test_null_interval_days_is_none_safe() -> None: + """`"interval_days": null` в jsonb → такт 1, а не TypeError (как в scheduler).""" + db = _mock_db() + update_schedule( + "cian_city_sweep", + ScheduleConfigUpdate( + window_start_hour=2, window_end_hour=5, default_params={"interval_days": None} + ), + db, + ) + assert _saved_params(db)["next_at"] is not None + + +def test_explicit_next_run_at_is_honored() -> None: + """Оператор явно задал момент — уважаем как есть, ничего не пересчитываем.""" + db = _mock_db() + wanted = datetime(2026, 9, 1, 3, 30, tzinfo=UTC) + + update_schedule( + "avito_full_load", + ScheduleConfigUpdate( + window_start_hour=13, + window_end_hour=15, + default_params={"interval_days": 7}, + next_run_at=wanted, + ), + db, + ) + + params = _saved_params(db) + assert params["next_at"] == wanted + assert params["explicit"] is True + + +def test_explicit_past_next_run_at_means_run_now() -> None: + """«Запустить сейчас» = момент в прошлом/now: планировщик берёт next_run_at <= NOW().""" + db = _mock_db() + now = datetime.now(tz=UTC) - timedelta(minutes=1) + + update_schedule( + "avito_full_load", + ScheduleConfigUpdate( + window_start_hour=13, window_end_hour=15, default_params={}, next_run_at=now + ), + db, + ) + + params = _saved_params(db) + assert params["next_at"] == now, "прошедший момент не должен подменяться пересчётом" + assert params["explicit"] is True + + +def test_no_explicit_next_run_at_flags_recompute() -> None: + """Без явного момента флаг explicit=false → SQL решает, двигать ли существующий.""" + db = _mock_db() + update_schedule( + "cian_city_sweep", + ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params={}), + db, + ) + assert _saved_params(db)["explicit"] is False + + +def test_upsert_preserves_future_run_when_window_and_interval_unchanged() -> None: + """Shape-check: ON CONFLICT не перезаписывает будущий next_run_at без причины. + + Само ветвление живёт в SQL (CASE), проверить его исполнение офлайн нечем — здесь + сторожим, что ветка не исчезла из запроса при следующей правке. + """ + db = _mock_db() + update_schedule( + "cian_city_sweep", + ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params={}), + db, + ) + sql = _saved_sql(db) + assert "scrape_schedules.next_run_at > NOW()" in sql + assert "THEN scrape_schedules.next_run_at" in sql + assert "CAST(:explicit AS boolean)" in sql # psycopg v3: CAST, не :explicit::boolean diff --git a/tradein-mvp/backend/tests/test_scrape_run_alert.py b/tradein-mvp/backend/tests/test_scrape_run_alert.py index 3b7d8546..0ab3bcd0 100644 --- a/tradein-mvp/backend/tests/test_scrape_run_alert.py +++ b/tradein-mvp/backend/tests/test_scrape_run_alert.py @@ -213,21 +213,28 @@ class TestMarkBannedAlertIntegration: # --------------------------------------------------------------------------- +# #2703: сторож читает counters, а НЕ колонку total_seen — у той DEFAULT 0, по ней +# «прогон не сообщил результата» неотличимо от «сообщил ноль». total_seen оставлен в +# фикстурах как исторический контекст: именно его читал прежний сторож. + + def _zero_row() -> SimpleNamespace: - """Успешно завершённый прогон ('done'), но 0 лотов — капча/пустая выдача-под- - видом-успеха (#2625).""" - return SimpleNamespace(status="done", total_seen=0) + """Успешно завершённый прогон ('done') с ИЗМЕРЕННЫМ нулём лотов — капча/пустая + выдача-под-видом-успеха (#2625).""" + return SimpleNamespace(status="done", counters={"lots_fetched": 0}, total_seen=0) def _nonzero_row(total_seen: int = 50) -> SimpleNamespace: """Успешно завершённый прогон с реальным результатом — прерывает "нулевой" стрик.""" - return SimpleNamespace(status="done", total_seen=total_seen) + return SimpleNamespace( + status="done", counters={"lots_fetched": total_seen}, total_seen=total_seen + ) def _other_status_row(status: str) -> SimpleNamespace: """failed/banned/cancelled — НЕ 'done', прерывает "нулевой" стрик (уже покрыт _alert_if_consecutive_failures отдельно).""" - return SimpleNamespace(status=status, total_seen=0) + return SimpleNamespace(status=status, counters={"lots_fetched": 0}, total_seen=0) class TestAlertIfConsecutiveZeroResults: @@ -301,7 +308,7 @@ def _make_db_for_mark_done(source: str, streak_rows: list[SimpleNamespace]) -> M Sequence of execute() calls in mark_done: 1. UPDATE scrape_runs SET status='done' ... RETURNING id → .first() 2. (_alert_on_run_id) SELECT source FROM scrape_runs WHERE id=... → .fetchone() - 3. (_alert_if_consecutive_zero_results) SELECT status, total_seen ... → .fetchall() + 3. (_alert_if_consecutive_zero_results) SELECT status, counters ... → .fetchall() """ db = MagicMock() diff --git a/tradein-mvp/backend/tests/test_scrape_skip_visibility.py b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py new file mode 100644 index 00000000..0683f683 --- /dev/null +++ b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py @@ -0,0 +1,537 @@ +"""Пропуск расписания оставляет след — строку scrape_runs(status='skipped') (#2658). + +До #2658 планировщик пропускал наступившее окно НЕМО: logger + сдвиг next_run_at, ни +строки прогона, ни изменения last_run_at. cian_history_backfill так простоял 37 дней на +протухших куках Циана, и снаружи это выглядело как «всё по расписанию». + +Покрываем: + 1. mark_skipped — INSERT новой строки и схлопывание подряд идущих одинаковых причин. + 2. Все ПЯТЬ мест, которые раньше пропускали молча: kit `_claim_run` ×3 (already_running / + concurrent_claim / running_appeared_under_lock), kit `scheduler_loop` (unknown_source) + и продуктовый cian `pre_claim`. + 3. Достижимость алерта: протухшие куки (load_session → None) дают ERROR-запись, которая + в scraper-контейнере становится событием GlitchTip (event_level=ERROR), — раньше + алерт стоял во второй ветке, куда на протухших куках исполнение не доходит. + 4. Неломание монитора нулевых прогонов (#2625): 'skipped' — не «прогон вернул ноль + лотов», обе alert-выборки его не видят и стрик им не прерывается. + +Без сети, без БД. +""" + +from __future__ import annotations + +import logging +import os +from datetime import UTC, datetime, timedelta +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 runs as kit_runs +from scraper_kit.orchestration import scheduler as kit_sched +from scraper_kit.orchestration.scheduler import ( + SKIP_ALREADY_RUNNING, + SKIP_CONCURRENT_CLAIM, + SKIP_RUNNING_UNDER_LOCK, + SKIP_UNKNOWN_SOURCE, + SchedulerContext, + _claim_run, +) + + +def _make_sched(source: str) -> dict[str, Any]: + return { + "id": 1, + "source": source, + "enabled": True, + "window_start_hour": 2, + "window_end_hour": 5, + "default_params": {}, + "last_run_id": None, + "last_run_at": None, + "next_run_at": None, + } + + +# ── 1. mark_skipped: INSERT + схлопывание ──────────────────────────────────── + + +class _FakeSkipDB: + """Session-мок для mark_skipped: UPDATE-ветка (схлопывание) vs INSERT-ветка.""" + + def __init__(self, *, collapse: bool) -> None: + self._collapse = collapse + self.statements: list[tuple[str, dict[str, Any]]] = [] + self.committed = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + sql = str(stmt) + self.statements.append((sql, params or {})) + result = MagicMock() + if "UPDATE scrape_runs r" in sql: + result.fetchone.return_value = MagicMock(id=7) if self._collapse else None + else: + result.fetchone.return_value = MagicMock(id=8) + return result + + def commit(self) -> None: + self.committed = True + + def sql_of(self, needle: str) -> tuple[str, dict[str, Any]] | None: + for sql, params in self.statements: + if needle in sql: + return sql, params + return None + + +def test_mark_skipped_inserts_row_with_machine_readable_reason() -> None: + db = _FakeSkipDB(collapse=False) + run_id = kit_runs.mark_skipped( + db, source="cian_history_backfill", reason="cian_cookies_expired", details="протухли" + ) + assert run_id == 8 + insert = db.sql_of("INSERT INTO scrape_runs") + assert insert is not None, "новая строка прогона не создана" + sql, params = insert + assert "'skipped'" in sql + assert params["source"] == "cian_history_backfill" + assert params["reason"] == "cian_cookies_expired" # слаг, не человеческий текст + assert "протухли" in params["counters"] + assert db.committed is True + + +def test_mark_skipped_collapses_consecutive_same_reason() -> None: + """Подряд идущие одинаковые пропуски не плодят строки — иначе тик 60с = строка/мин.""" + db = _FakeSkipDB(collapse=True) + run_id = kit_runs.mark_skipped(db, source="avito_full_load", reason=SKIP_ALREADY_RUNNING) + assert run_id == 7 + assert db.sql_of("INSERT INTO scrape_runs") is None, "схлопывание не сработало" + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, _params = update + assert "skips" in sql # счётчик повторов растёт вместо новой строки + + +def test_mark_skipped_collapse_refreshes_detail_and_started_at() -> None: + """Схлопывание освежает строку: detail, started_at (иначе живой стрик тонет в списке). + + Список прогонов сортирует ORDER BY started_at DESC с limit=20 — замороженный + started_at утопил бы 37-дневный стрик под свежими прогонами других источников. + Начало стрика сохраняется в counters.first_skip_at. + """ + db = _FakeSkipDB(collapse=True) + kit_runs.mark_skipped( + db, source="cian_history_backfill", reason="cian_cookies_expired", details="37 дн. назад" + ) + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, params = update + # clock_timestamp(), а не NOW(): отметки времени прогона пишутся настоящими + # часами, иначе внутри долгой открытой транзакции они замерзают (#2702). + assert "started_at = clock_timestamp()" in sql + assert "'detail', CAST(:details AS text)" in sql, "detail замерзает от первого пропуска" + assert "first_skip_at" in sql, "начало стрика потеряно" + assert params["details"] == "37 дн. назад" + + +def test_mark_skipped_latest_lookup_uses_indexed_order() -> None: + """Поиск последней строки идёт по (source, started_at DESC) — индекс из миграции 015. + + ORDER BY id DESC этот индекс не использует: для unknown_source (тик каждые 60 с + бессрочно) это был бы отбор всех строк источника с сортировкой раз в минуту. + """ + db = _FakeSkipDB(collapse=True) + kit_runs.mark_skipped(db, source="avito_full_load", reason=SKIP_ALREADY_RUNNING) + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, _params = update + assert "ORDER BY started_at DESC, id DESC" in sql + + +# ── 2. пять мест: kit _claim_run ×3 ────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, *, scalar: Any = None, fetchone: Any = None) -> None: + self._scalar = scalar + self._fetchone = fetchone + + def scalar(self) -> Any: + return self._scalar + + def fetchone(self) -> Any: + return self._fetchone + + +class _FakeClaimDB: + """Мок Session для _claim_run (тот же сценарный контракт, что в parity-тестах).""" + + def __init__(self, *, running_states: list[bool], lock: bool = True) -> None: + self._running = list(running_states) + self._lock = lock + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt) + if "SELECT 1 FROM scrape_runs" in sql: + return _FakeResult(fetchone=(1,) if self._running.pop(0) else None) + if "pg_try_advisory_xact_lock" in sql: + return _FakeResult(scalar=self._lock) + return _FakeResult() + + def commit(self) -> None: + pass + + def rollback(self) -> None: + self.rolled_back = True + + +def _ctx() -> SchedulerContext: + runs = MagicMock() + runs.create_run = MagicMock(return_value=42) + return SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(), + runs=runs, + ) + + +@pytest.mark.parametrize( + ("running_states", "lock", "expected_reason"), + [ + ([True], True, SKIP_ALREADY_RUNNING), + ([False], False, SKIP_CONCURRENT_CLAIM), + ([False, True], True, SKIP_RUNNING_UNDER_LOCK), + ], +) +def test_claim_run_skip_writes_skipped_row( + running_states: list[bool], lock: bool, expected_reason: str +) -> None: + """Каждая из трёх skip-веток _claim_run пишет строку прогона с своей причиной.""" + db = _FakeClaimDB(running_states=running_states, lock=lock) + with patch.object(kit_runs, "mark_skipped") as mark: + run_id = _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + + assert run_id is None + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == expected_reason + assert mark.call_args.kwargs["source"] == "avito_city_sweep" + + +def test_claim_run_under_lock_rolls_back_before_writing() -> None: + """Строка пишется ПОСЛЕ rollback'а — иначе её INSERT улетел бы в откат лока.""" + db = _FakeClaimDB(running_states=[False, True], lock=True) + order: list[str] = [] + real_rollback = db.rollback + + def _rollback() -> None: + order.append("rollback") + real_rollback() + + db.rollback = _rollback # type: ignore[method-assign] + with patch.object(kit_runs, "mark_skipped", side_effect=lambda *a, **k: order.append("skip")): + _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + + assert order == ["rollback", "skip"] + + +def test_claim_run_happy_path_writes_no_skip_row() -> None: + """Успешный claim не должен оставлять skip-строк (иначе счётчики мусорные).""" + db = _FakeClaimDB(running_states=[False, False], lock=True) + with patch.object(kit_runs, "mark_skipped") as mark: + run_id = _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + assert run_id == 42 + mark.assert_not_called() + + +# ── 2b. пятое место: scheduler_loop с неизвестным source ───────────────────── + + +async def test_scheduler_loop_unknown_source_writes_skipped_row() -> None: + """enabled-расписание без handler'а: раньше — warning каждый тик и ноль следов.""" + ctx = SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(return_value=MagicMock()), + runs=MagicMock(), + # первый вызов (верх тика) — работаем, дальше — drain, чтобы выйти из while True + shutdown_requested=MagicMock(side_effect=[False, True, True]), + ) + with ( + patch.object(kit_sched.asyncio, "sleep", AsyncMock()), + patch.object(kit_sched, "reap_zombies", MagicMock(return_value=0)), + patch.object( + kit_sched, + "get_due_schedules", + MagicMock(return_value=[_make_sched("source_from_mars")]), + ), + patch.object(kit_runs, "mark_skipped") as mark, + ): + await kit_sched.scheduler_loop(ctx, registry={}) + + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == SKIP_UNKNOWN_SOURCE + assert mark.call_args.kwargs["source"] == "source_from_mars" + + +# ── 3. cian pre_claim: строка + достижимый алерт ───────────────────────────── + + +def _patch_cian( + *, + cookies: dict[str, str] | None, + expires_at: datetime | None, + verify: Any = None, +) -> Any: + from app.services import cian_session + + return ( + patch.object(cian_session, "load_session", MagicMock(return_value=cookies)), + patch.object(cian_session, "session_expires_at", MagicMock(return_value=expires_at)), + patch.object(cian_session, "verify_session", AsyncMock(return_value=verify)), + ) + + +async def test_cian_pre_claim_expired_cookies_logs_error() -> None: + """Фальсификация #2658-2: на протухших куках ДОЛЖНА быть ERROR-запись. + + Именно ERROR — в scraper-контейнере GlitchTip поднят с + LoggingIntegration(event_level=ERROR) (scheduler_main.py), warning событием не станет. + Старый код в этой ветке писал только logger.warning, а capture_message стоял во + второй ветке (verify_session → None), недостижимой при протухании: load_session сам + фильтрует expires_at_estimate > NOW(). + """ + from app.services import product_handlers + + expired = datetime.now(tz=UTC) - timedelta(days=37) + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=expired) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()), + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is False + errors = [r for r in records if r.levelno >= logging.ERROR] + assert errors, "протухание кук не породило ERROR-запись → события GlitchTip не будет" + + +async def test_cian_pre_claim_expired_cookies_writes_skipped_row() -> None: + """Немой `return False` заменён строкой прогона с причиной cian_cookies_expired.""" + from app.services import product_handlers + + expired = datetime.now(tz=UTC) - timedelta(days=37) + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=expired) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()) as defer, + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert proceed is False + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_EXPIRED + assert mark.call_args.kwargs["source"] == "cian_history_backfill" + defer.assert_called_once() # next_run_at по-прежнему двигаем (не долбим Циан каждые 60с) + + +async def test_cian_pre_claim_missing_cookies_reason_differs_from_expired() -> None: + """«Кук нет вовсе» и «протухли» — разные слаги: причина машиночитаема.""" + from app.services import product_handlers + + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=None) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_MISSING + assert product_handlers.SKIP_CIAN_COOKIES_MISSING != product_handlers.SKIP_CIAN_COOKIES_EXPIRED + + +async def test_cian_pre_claim_rejected_cookies_writes_skipped_row() -> None: + """Вторая ветка (Циан не принимает куки) — тоже строка, а не только алерт.""" + from app.services import product_handlers + + future = datetime.now(tz=UTC) + timedelta(days=20) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=future, verify=None + ) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert proceed is False + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_INVALID + + +async def test_cian_pre_claim_warns_before_expiry_not_after() -> None: + """Предупреждаем ЗАРАНЕЕ: куки ещё рабочие, но жить им меньше COOKIE_EXPIRY_WARN_DAYS. + + Обновление кук — ручная операция; алерт по факту протухания приходит, когда сбор уже + встал. Гейт при этом пропускает прогон (proceed=True) — предупреждение, не блокировка. + """ + from app.services import cian_session, product_handlers + + soon = datetime.now(tz=UTC) + timedelta(days=cian_session.COOKIE_EXPIRY_WARN_DAYS - 1) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=soon, verify={"user": {"isAuthenticated": True}} + ) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with p_load, p_exp, p_verify: + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + # Срок считаем по ЗАПИСИ, которую взял load_session: при нескольких аккаунтах + # свежайшая-любая может быть чужой протухшей строкой (валидности она не знает). + assert cian_session.session_expires_at.call_args.kwargs == {"valid_only": True} + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is True + assert [r for r in records if r.levelno >= logging.ERROR], "не предупредили заранее" + + +async def test_cian_pre_claim_fresh_cookies_are_silent() -> None: + """Свежие куки — ни алерта, ни skip-строки (иначе алерт-усталость).""" + from app.services import product_handlers + + far = datetime.now(tz=UTC) + timedelta(days=25) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=far, verify={"user": {"isAuthenticated": True}} + ) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is True + mark.assert_not_called() + assert not [r for r in records if r.levelno >= logging.ERROR] + + +# ── 4. монитор нулевых прогонов не смешивается с пропусками ────────────────── + + +class _RecordingDB: + """Возвращает заданные строки на SELECT и запоминает SQL (для проверки фильтров).""" + + def __init__(self, rows: list[Any]) -> None: + self.rows = rows + self.sql: list[str] = [] + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + self.sql.append(str(stmt)) + result = MagicMock() + result.fetchall.return_value = self.rows + return result + + +def test_zero_result_monitor_ignores_skipped_rows() -> None: + """'skipped' НЕ участвует в стрике нулевых прогонов — это не «прогон вернул ноль». + + Смешать их в одном счётчике нельзя в обе стороны: пропуск не должен ни считаться + нулевым прогоном, ни прерывать стрик реальных нулевых. Отсечка делается в SQL — + проверяем, что 'skipped' не попал в список статусов выборки. + """ + db = _RecordingDB(rows=[]) + kit_runs._alert_if_consecutive_zero_results(db, "cian_city_sweep") + kit_runs._alert_if_consecutive_failures(db, "cian_city_sweep") + + assert db.sql, "alert-выборка не выполнилась" + for sql in db.sql: + assert "status IN ('failed', 'banned', 'done', 'cancelled')" in sql + assert "skipped" not in sql + + +def test_admin_runs_filter_accepts_skipped_status() -> None: + """GET /admin/scrape/runs?status=skipped не должен отдавать 422. + + Единственная поверхность, где оператор спрашивает «что сейчас пропускается» — фильтр + статуса в таблице прогонов. Без 'skipped' в Literal строки видны, а вопрос задать + нельзя, то есть цель #2658 на UI достигнута наполовину. + """ + import typing + + from app.api.v1.admin import list_scrape_runs_unified + + annotation = typing.get_type_hints(list_scrape_runs_unified, include_extras=True)["status"] + allowed: set[str] = set() + for arg in typing.get_args(typing.get_args(annotation)[0]): + allowed.update(typing.get_args(arg)) + assert "skipped" in allowed + + +def test_mark_skipped_status_is_not_a_failure_status() -> None: + """Строка-пропуск не попадает и в failed/banned-стрик (алерт «3 подряд ошибки»).""" + db = _FakeSkipDB(collapse=False) + kit_runs.mark_skipped(db, source="cian_history_backfill", reason="cian_cookies_expired") + insert = db.sql_of("INSERT INTO scrape_runs") + assert insert is not None + sql, _params = insert + assert "'skipped'" in sql + assert "'failed'" not in sql and "'banned'" not in sql diff --git a/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py b/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py index f5e3b247..784df63d 100644 --- a/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py +++ b/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py @@ -7,7 +7,8 @@ эстиматору было видно лишь ~45.6% активного инвентаря (CIAN — 5.9%). Фикс: и ON CONFLICT DO UPDATE, и dedup-drift reconcile UPDATE теперь выставляют -scraped_at = NOW() рядом с last_seen_at = NOW(). Проверяем оба пути в +scraped_at рядом с last_seen_at (с #2731 — statement_timestamp(), см. +test_2731_observation_timestamps.py; до него NOW()). Проверяем оба пути в `scraper_kit.base` (единственный живой модуль — `app.services.scrapers.base` удалён #2397 финальный шаг E, mirror-тесты через legacy убраны) и свойства ретро-бэкфилл-миграции 161. @@ -120,7 +121,12 @@ def _kit_matcher() -> MagicMock: def test_kit_on_conflict_bumps_scraped_at() -> None: - """kit ON CONFLICT DO UPDATE двигает scraped_at = NOW().""" + """kit ON CONFLICT DO UPDATE двигает scraped_at рядом с last_seen_at. + + #2731: обе метки пишутся statement_timestamp() (построчно, но одинаково внутри + statement'а) вместо NOW() — инвариант #2206 «scraped_at двигается вместе с + last_seen_at» от этого не меняется, меняется только источник времени. + """ db = _mock_db_update_path(inserted=False) lot = KitLot( source="cian", @@ -134,12 +140,12 @@ def test_kit_on_conflict_bumps_scraped_at() -> None: assert (inserted, updated) == (0, 1) sql = _find_sql(db, "INSERT INTO listings (") - assert "scraped_at = NOW()" in sql - assert "last_seen_at = NOW()" in sql + assert "scraped_at = statement_timestamp()" in sql + assert "last_seen_at = statement_timestamp()" in sql def test_kit_reconcile_bumps_scraped_at() -> None: - """kit dedup-drift reconcile UPDATE двигает scraped_at = NOW().""" + """kit dedup-drift reconcile UPDATE двигает scraped_at рядом с last_seen_at (#2731).""" db = _mock_db_reconcile(reconcile_id=88) lot = KitLot( source="avito", @@ -152,8 +158,8 @@ def test_kit_reconcile_bumps_scraped_at() -> None: kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66) sql = _find_sql(db, "SET dedup_hash") - assert "scraped_at = NOW()" in sql - assert "last_seen_at = NOW()" in sql + assert "scraped_at = statement_timestamp()" in sql + assert "last_seen_at = statement_timestamp()" in sql # ── Migration 161: retro-backfill scraped_at ────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index 6e472637..813d78a2 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -42,12 +42,15 @@ def client() -> TestClient: def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: - """list_all → total + rows; ISO-форматирование дат + error_text/run_type.""" + """list_all → total + rows; ISO-форматирование дат + error_text. + + run_type из выдачи убран (#2674): вырожденная колонка (одно значение во всех + 3244 прогонах), вид прогона называет source. + """ fake_rows = [ { "run_id": 5, "source": "avito_city_sweep", - "run_type": "city_sweep", "status": "done", "params": {"pages_per_anchor": 3}, "counters": {"lots_fetched": 120}, @@ -68,7 +71,7 @@ def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: row = body["rows"][0] assert row["run_id"] == 5 assert row["source"] == "avito_city_sweep" - assert row["run_type"] == "city_sweep" + assert "run_type" not in row assert row["total_seen"] == 120 assert row["new_count"] == 30 assert row["error_text"] is None @@ -153,7 +156,8 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None: with ( patch.object(admin_module, "_probe_browser_health", fake_browser_health), patch.object(admin_module, "_probe_current_ip", fake_current_ip), - patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]), + # #2830: узел резолвится из пула по (db, source), а не из статичного env + patch.object(admin_module, "_provider_proxy_url", lambda _db, s: proxy_urls[s]), patch.object(admin_module.settings, "scraper_fetch_mode", "browser"), ): r = client.get("/api/v1/admin/scraper/health") @@ -204,7 +208,7 @@ def test_health_browser_unreachable(client: TestClient) -> None: with ( patch.object(admin_module.httpx, "AsyncClient", _BoomClient), patch.object(admin_module, "_probe_current_ip", fake_current_ip), - patch.object(admin_module, "_provider_proxy_url", lambda s: None), + patch.object(admin_module, "_provider_proxy_url", lambda _db, _s: None), ): r = client.get("/api/v1/admin/scraper/health") @@ -357,10 +361,12 @@ def _make_dq_db_mock() -> MagicMock: """Мок db-сессии для GET /scraper/data-quality.""" db = MagicMock() - # listings rows: avito=1000 active, cian=500 active + # listings rows: avito=1000 active (все свежие), cian=500 active / 300 протухших + # (#2660: stale_count идёт рядом с active_count — «активно» ≠ «живо»). avito_row = { "source": "avito", "active_count": 1000, + "stale_count": 0, "f_description": 900, "f_photo_urls": 980, "f_address": 1000, @@ -368,13 +374,13 @@ def _make_dq_db_mock() -> MagicMock: "f_lon": 950, "f_kitchen_area_m2": 600, "f_living_area_m2": 100, - "f_ceiling_height": 50, "f_ceiling_height_m": 300, "f_metro_stations": 700, } cian_row = { "source": "cian", "active_count": 500, + "stale_count": 300, "f_description": 450, "f_photo_urls": 490, "f_address": 500, @@ -382,8 +388,10 @@ def _make_dq_db_mock() -> MagicMock: "f_lon": 480, "f_kitchen_area_m2": 400, "f_living_area_m2": 400, - "f_ceiling_height": 350, - "f_ceiling_height_m": 0, + # #2699: cian тоже пишет в канон ceiling_height_m — раньше в фикстуре + # стояло f_ceiling_height=350 / f_ceiling_height_m=0, ровно тот прод-раскол, + # из-за которого 855 циановских значений не видел ни один потребитель. + "f_ceiling_height_m": 350, "f_metro_stations": 100, } @@ -435,6 +443,7 @@ def test_data_quality_shape(client: TestClient) -> None: avito = sources["avito"] assert avito["active_count"] == 1000 + assert avito["stale_count"] == 0 # #2660 fields = avito["fields"] assert 0.0 <= fields["description"] <= 100.0 assert fields["description"] == pytest.approx(90.0, abs=0.1) @@ -443,8 +452,9 @@ def test_data_quality_shape(client: TestClient) -> None: cian = sources["cian"] assert cian["active_count"] == 500 - assert cian["fields"]["ceiling_height"] == pytest.approx(70.0, abs=0.1) - assert cian["fields"]["ceiling_height_m"] == pytest.approx(0.0, abs=0.1) + assert cian["stale_count"] == 300 # #2660: 60% «активных» на деле не виделись + assert cian["fields"]["ceiling_height_m"] == pytest.approx(70.0, abs=0.1) + assert "ceiling_height" not in cian["fields"] # #2699: DEPRECATED, убрана houses = body["houses"] assert houses["total"] == 2000 diff --git a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py index bb924d09..43cbe845 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py @@ -188,7 +188,13 @@ async def test_house_imv_backfill_browser_fetcher_uses_settings_endpoint() -> No ] await house_imv_backfill.backfill_house_imv(db, batch_size=1) - assert captured == {"source": "avito", "endpoint": settings.browser_http_endpoint} + # #2698: к endpoint= добавилась проводка пула — без неё сайдкар брал env-прокси + # (узел с affinity='domclick'), и домовая оценка падала 34 дня подряд. + assert captured["source"] == "avito" + assert captured["endpoint"] == settings.browser_http_endpoint + assert captured["proxy_provider"] is not None + assert captured["use_pool"] == settings.use_proxy_pool_browser + assert captured["environment"] == settings.environment async def test_cian_history_backfill_browser_fetcher_uses_settings_endpoint() -> None: @@ -230,9 +236,14 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi captured: dict[str, Any] = {} - async def _fake_fetch_newbuilding(zhk_url: str, *, config: Any = None) -> None: + async def _fake_fetch_newbuilding( + zhk_url: str, *, config: Any = None, proxy_provider: Any = None + ) -> None: captured["zhk_url"] = zhk_url captured["endpoint"] = config.browser_http_endpoint if config is not None else None + # #2767: этот путь тоже обязан идти через пул прокси — до правки он, как и + # обогащение, выходил через единственный env-узел сайдкара. + captured["proxy_provider"] = proxy_provider db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [ @@ -252,6 +263,7 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi assert captured["zhk_url"] == "https://zhk-test-ekb-i.cian.ru/" assert captured["endpoint"] == settings.browser_http_endpoint + assert captured["proxy_provider"] is not None async def test_cian_history_backfill_valuations_estimate_gets_config() -> None: diff --git a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py index b372a63e..d2890511 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py @@ -59,11 +59,16 @@ def _spy_browser_fetcher(html: str) -> MagicMock: @pytest.mark.asyncio async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch): - """config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...).""" - spy = _spy_browser_fetcher("") - monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy) + """config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...). - config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT) + С #2767 путь идёт через `build_browser_fetcher` (подключение к пулу прокси), поэтому + подменяется BrowserFetcher В ФАБРИКЕ — сама фабрика при этом настоящая, и тест + проверяет всю цепочку config → фабрика → фетчер, а не контракт мока. + """ + spy = _spy_browser_fetcher("") + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) + + config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False) await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", config=config) spy.assert_called_once() diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py index 18b796cb..f3091685 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py @@ -43,6 +43,7 @@ class _RunsRecorder: def __init__(self) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] + self.ban_kinds: list[str] = [] def is_cancelled(self, db: Any, run_id: int) -> bool: return False @@ -53,7 +54,16 @@ class _RunsRecorder: def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: self.calls.append(("mark_done", dict(counters))) - def mark_banned(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + def mark_banned( + self, + db: Any, + run_id: int, + error: str, + counters: dict[str, Any], + *, + ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля + ) -> None: + self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус self.calls.append(("mark_banned", dict(counters))) def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: @@ -442,3 +452,33 @@ async def test_avito_city_sweep_passes_proxy_provider_to_scraper_constructor() - avito_scraper_cls = capture["avito_scraper_cls"] avito_scraper_cls.assert_called_once() assert avito_scraper_cls.call_args.kwargs.get("proxy_provider") is sentinel + + +# ── #2701: снимок обязан знать свой прогон ──────────────────────────────────── +# +# Замер на проде до правки: listings_snapshots 396 162 строки, run_id заполнен у +# 245 647 (62.0%); по avito 80.3%, и у ВСЕХ дневных city-sweep строк (напр. 944 за +# 2026-08-06, 635 за 2026-08-05) run_id пуст — sweep его просто не передавал, хотя +# держал в своей же сигнатуре и логировал в каждой строке. +# +# Вторая половина цепочки (save_listings прокидывает run_id в upsert_listing_snapshot) +# уже под замком: test_snapshot_writer.py::test_save_listings_snapshot_receives_run_id. + + +@pytest.mark.asyncio +async def test_avito_city_sweep_passes_run_id_to_save_listings() -> None: + """run_avito_city_sweep(run_id=1) → save_listings(..., run_id=1) → снимок с прогоном. + + Falsification: убрать `run_id=run_id` из вызова save_listings в pipeline.py — + kwargs не содержит run_id, assert падает. + """ + scenario = _Scenario( + anchors=_ANCHORS_2, + per_anchor=[("lots", 3, 3, 0), ("lots", 2, 2, 0)], + ) + capture: dict[str, Any] = {} + await _drive(scenario, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_count == 2, "оба anchor'а сохраняют — проверяем оба вызова" + for call in save_mock.call_args_list: + assert call.kwargs.get("run_id") == 1, "снимок anchor'а остался бы без прогона" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index 5dae8cb1..ba629b56 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -9,7 +9,7 @@ sweep). Здесь — остальные 7 sweep'ов (#2135 F2): City sweep'ы (приоритет — активны в проде): - run_yandex_city_sweep — combos SERP + save + price-history - run_cian_city_sweep — SERP + newbuilding_only-фильтр + save - - run_domclick_city_sweep — BFF citywide + честный статус (done / failed) + - run_domclick_city_sweep — BFF citywide + честный статус (done / failed / banned) Плюс: - run_avito_newbuilding_sweep — citywide novostroyka SERP + save Full load'ы (smoke — импорт + базовый прогон через on_bucket): @@ -39,6 +39,7 @@ from scraper_kit.orchestration.pipeline import ( run_yandex_city_sweep, run_yandex_full_load, ) +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS PFX = "scraper_kit.orchestration.pipeline" @@ -52,6 +53,7 @@ class _RunsRecorder: def __init__(self) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] + self.ban_kinds: list[str] = [] def is_cancelled(self, db: Any, run_id: int) -> bool: return False @@ -62,7 +64,16 @@ class _RunsRecorder: def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: self.calls.append(("mark_done", dict(counters))) - def mark_banned(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + def mark_banned( + self, + db: Any, + run_id: int, + error: str, + counters: dict[str, Any], + *, + ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля + ) -> None: + self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус self.calls.append(("mark_banned", dict(counters))) def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: @@ -388,7 +399,11 @@ async def test_cian_city_sweep_honest_empty_stays_done() -> None: async def _drive_domclick( - *, lots_n: int, blocked: bool, capture: dict[str, Any] | None = None + *, + lots_n: int, + blocked: bool, + fetch_errors: int = 0, + capture: dict[str, Any] | None = None, ) -> _DriveResult: """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...).""" recorder = _RunsRecorder() @@ -398,7 +413,11 @@ async def _drive_domclick( fetch_city=AsyncMock(return_value=lots), blocked=blocked, geo_filtered=0, - fetch_errors=0, + fetch_errors=fetch_errors, + # #2670: полный охват по умолчанию — эти фикстуры про блок/ошибки, не про обрыв + # (частичный охват проверяется в test_2670_streak_and_partial_coverage.py). + buckets_completed=len(ROOM_BUCKETS), + buckets_total=len(ROOM_BUCKETS), ) save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else []) if capture is not None: @@ -425,14 +444,45 @@ async def test_domclick_city_sweep_done() -> None: @pytest.mark.asyncio -async def test_domclick_city_sweep_blocked_failed() -> None: - """QRATOR-блок + 0 lots → mark_failed (честный статус #1968).""" +async def test_domclick_city_sweep_blocked_banned() -> None: + """QRATOR-блок + 0 lots → mark_banned (честный статус #1968, #2657).""" counters, calls = await _drive_domclick(lots_n=0, blocked=True) assert counters["lots_fetched"] == 0 assert counters["blocked"] == 1 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_blocked_with_lots_marks_banned() -> None: + """#2657: блок оборвал бакеты ПОСЛЕ части лотов → banned, не done. + + Прод-случай: 13 из 13 прогонов с blocked=1 уходили в done, потому что + honest-status требовал ещё и lots_fetched == 0. + """ + counters, calls = await _drive_domclick(lots_n=4, blocked=True) + assert counters["lots_fetched"] == 4 + assert counters["blocked"] == 1 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_fetch_errors_without_block_stays_failed() -> None: + """#2657 анти-оверрич: 0 лотов + fetch-ошибки, но БЕЗ блока → failed, не banned.""" + counters, calls = await _drive_domclick(lots_n=0, blocked=False, fetch_errors=2) + assert counters["blocked"] == 0 + assert counters["errors_count"] == 2 assert calls[-1][0] == "mark_failed" +@pytest.mark.asyncio +async def test_domclick_city_sweep_honest_empty_stays_done() -> None: + """#2657 анти-оверрич: честная пустота (0 лотов, ни блока, ни ошибок) → done.""" + counters, calls = await _drive_domclick(lots_n=0, blocked=False) + assert counters["lots_fetched"] == 0 + assert counters["blocked"] == 0 + assert calls[-1][0] == "mark_done" + + # ── Avito newbuilding sweep ─────────────────────────────────────────────────── @@ -830,3 +880,24 @@ async def test_full_load_honest_empty_stays_done(source: str) -> None: counters, calls = await _drive_full_load_empty(source=source, attempts=6, failures=0) assert counters["unique_fetched"] == 0 assert calls[-1][0] == "mark_done" + + +# ── #2701: снимок обязан знать свой прогон ──────────────────────────────────── +# +# Замер на проде до правки: run_id пуст у 150 515 из 396 162 снимков (38%). +# Из девяти вызовов save_listings в pipeline.py шесть передавали run_id, три нет; +# у двух из трёх (city sweep + этот novostroyka-обход) run_id лежал в той же функции. + + +@pytest.mark.asyncio +async def test_avito_newbuilding_sweep_passes_run_id_to_save_listings() -> None: + """run_avito_newbuilding_sweep(run_id=1) → save_listings(..., run_id=1). + + Falsification: убрать `run_id=run_id` из вызова save_listings — kwargs пуст, assert падает. + """ + capture: dict[str, Any] = {} + await _drive_nb_sweep(capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_count > 0 + for call in save_mock.call_args_list: + assert call.kwargs.get("run_id") == 1, "novostroyka-снимок остался бы без прогона" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index ca9f0aab..b973e19a 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -71,6 +71,7 @@ _PRODUCT_SOURCES: set[str] = { "newbuilding_enrich", "yandex_newbuilding_sweep", "geoportal_coords_backfill", + "house_coords_from_listings", "geocode_missing_listings", "avito_detail_backfill", "yandex_detail_backfill", @@ -238,6 +239,8 @@ class _FakeClaimDB: self.committed = False self.rolled_back = False self.update_calls = 0 + # #2658: строки-пропуски (scrape_runs status='skipped'), которые пишет mark_skipped. + self.skip_rows = 0 def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) @@ -249,6 +252,13 @@ class _FakeClaimDB: if "UPDATE scrape_schedules" in sql: self.update_calls += 1 return _FakeResult() + # #2658 mark_skipped: сначала пробует схлопнуть последнюю skip-строку (UPDATE + # ... RETURNING), не нашёл — вставляет новую. + if "UPDATE scrape_runs r" in sql: + return _FakeResult(fetchone=None) + if "INSERT INTO scrape_runs" in sql: + self.skip_rows += 1 + return _FakeResult(fetchone=MagicMock(id=999)) return _FakeResult() def commit(self) -> None: @@ -286,6 +296,7 @@ def test_claim_run_skip_already_running() -> None: run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx) assert run_id is None ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658: пропуск оставляет строку, а не только лог def test_claim_run_skip_lock_busy() -> None: @@ -295,6 +306,7 @@ def test_claim_run_skip_lock_busy() -> None: run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx) assert run_id is None ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658 def test_claim_run_running_appeared_under_lock() -> None: @@ -305,6 +317,7 @@ def test_claim_run_running_appeared_under_lock() -> None: assert run_id is None assert db.rolled_back is True ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658 # ── 3. reap_zombies ────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_search_api.py b/tradein-mvp/backend/tests/test_search_api.py index 0a7fc383..e6c6c1ad 100644 --- a/tradein-mvp/backend/tests/test_search_api.py +++ b/tradein-mvp/backend/tests/test_search_api.py @@ -39,7 +39,6 @@ def test_build_query_all_filters(): area_m2_max=80, price_rub_max=10_000_000, year_built_min=2000, - has_kadastr=True, multi_source_only=True, require_avito=True, address_query="Малышева", @@ -49,13 +48,28 @@ def test_build_query_all_filters(): assert "total_area >= CAST(:area_min AS double precision)" in sql assert "price_rub <= CAST(:price_max AS bigint)" in sql assert "year_built >= CAST(:yb_min AS integer)" in sql - assert "cadastral_number IS NOT NULL" in sql assert "source_count >= 2" in sql assert "has_avito = true" in sql assert "address ILIKE" in sql assert args["addr_like"] == "%Малышева%" +def test_no_unsatisfiable_cadastral_filter() -> None: + """#2674: фильтр по кадастру снят — он мог вернуть только пустую выдачу. + + `listings.cadastral_number` (кадастр КВАРТИРЫ) пуст у всех 93 408 объявлений на проде: + площадки его не отдают. Предикат `cadastral_number IS NOT NULL` в WHERE обещал + пользователю фильтр качества данных, которого не существует. Ни один набор параметров + поиска не должен уметь его собрать. RED до фикса: has_kadastr=True вставлял предикат. + """ + for params in ( + SearchParams(), + SearchParams(**{"has_kadastr": True}), # старый клиент — параметр игнорируется + ): + sql, _ = build_search_query(params) + assert "cadastral_number IS NOT NULL" not in sql + + def test_build_query_sources_array(): sql, args = build_search_query(SearchParams(sources=["avito", "cian"])) assert "sources && CAST(:sources AS text[])" in sql @@ -150,7 +164,7 @@ def test_search_cache_hit(_reset_cache_singleton): } _reset_cache_singleton.get = AsyncMock(return_value=cached_payload) client = TestClient(app) - r = client.post("/api/v1/search", json={"rooms": 2}) + r = client.post("/api/v1/search", json={"rooms": 2}, headers={"X-Authenticated-User": "admin"}) assert r.status_code == 200 assert r.json()["cache_hit"] is True _reset_cache_singleton.set.assert_not_awaited() diff --git a/tradein-mvp/backend/tests/test_snapshot_writer.py b/tradein-mvp/backend/tests/test_snapshot_writer.py index df1aafde..a779c98d 100644 --- a/tradein-mvp/backend/tests/test_snapshot_writer.py +++ b/tradein-mvp/backend/tests/test_snapshot_writer.py @@ -119,7 +119,7 @@ def test_upsert_snapshot_params_minimal(): assert p["run_id"] is None assert p["snap_date"] is None assert p["ppm2"] is None - assert p["pos"] is None + assert "pos" not in p # #2674: position_in_serp больше не пишется assert p["status"] == "active" @@ -134,7 +134,6 @@ def test_upsert_snapshot_params_full(): price_per_m2=130_000, run_id=99, snapshot_date=snap_date, - position_in_serp=3, status="active", ) @@ -144,10 +143,30 @@ def test_upsert_snapshot_params_full(): assert params["ppm2"] == 130_000 assert params["run_id"] == 99 assert params["snap_date"] == snap_date - assert params["pos"] == 3 assert params["status"] == "active" +def test_upsert_snapshot_does_not_write_position_in_serp(): + """#2674: колонка не упоминается ни в сигнатуре, ни в SQL. + + Диагноз — «механизм невыразим»: PK (listing_id, snapshot_date) держит одну строку + на объявление в СУТКИ, а позиция есть свойство конкретного прогона выдачи с + конкретными фильтрами (в одни сутки по ключу пишут до 13 прогонов и 4 разных + SERP-источника). Значение осело бы от последнего писателя дня и читалось бы как + факт. Тест ловит попытку «подключить проводку» обратно. + """ + import inspect + + from scraper_kit.snapshot_writer import upsert_listing_snapshot as fn + + assert "position_in_serp" not in inspect.signature(fn).parameters + + db = _mock_db_simple() + upsert_listing_snapshot(db, listing_id=1, price_rub=1_000_000) + sql = str(db.execute.call_args_list[0].args[0]) + assert "position_in_serp" not in sql + + def test_upsert_snapshot_no_commit(): """upsert_listing_snapshot НЕ вызывает db.commit() — commit делает caller.""" db = _mock_db_simple() diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index c1ea1249..003883e1 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -107,6 +107,64 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N assert extract_street_name(address) == expected +@pytest.mark.parametrize( + "address,expected", + [ + # Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a + # trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading + # postal index + admin parts ("620105, Свердловская обл, г + # Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required + # the dot → keyword never matched on ANY DaData address → street-deals + # / sales-vs-listings endpoints silently returned empty for every + # DaData-geocoded request, not just this one. + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "ул Академика Парина, д 46/5", + "Академика Парина", + ), + # Same address, WITH the dot — must give the identical result (dot + # optional, not dot-forbidden). + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "ул. Академика Парина, д 46/5", + "Академика Парина", + ), + # Same address, full word "улица" — the alternation-order/backtracking + # concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave + # "ица ..." behind. + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "улица Академика Парина, д 46/5", + "Академика Парина", + ), + # Without the leading postal index — same admin prefix otherwise. + ( + "Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5", + "Академика Парина", + ), + # Bare street+house, no admin prefix at all. + ("ул Академика Парина, д 46/5", "Академика Парина"), + # Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр). + ("г Екатеринбург, пр Ленина, 5", "Ленина"), + ("г Екатеринбург, пер Красный, 4", "Красный"), + ("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"), + # "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression). + ("Екатеринбург, ул. Малышева, 1", "Малышева"), + ("Екатеринбург, ул Малышева, 1", "Малышева"), + ("Екатеринбург, улица Малышева, 1", "Малышева"), + ], +) +def test_extract_street_name_dadata_no_dot_abbreviations( + address: str | None, expected: str | None +) -> None: + """#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses + use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр") + — _STREET_KW_RE must match them exactly like the dotted forms.""" + from app.services.estimator import extract_street_name + + assert extract_street_name(address) == expected + + # ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_version_api.py b/tradein-mvp/backend/tests/test_version_api.py new file mode 100644 index 00000000..767e6b87 --- /dev/null +++ b/tradein-mvp/backend/tests/test_version_api.py @@ -0,0 +1,99 @@ +"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py + +app/api/v1/version.py. + +Isolated FastAPI app (no full app.main import, no DB) — same pattern as +tests/test_geocode_reverse_api.py: mount only the router under test. +""" + +from __future__ import annotations + +import importlib +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import version as version_module +from app.core import version as version_core + + +@pytest.fixture +def app() -> FastAPI: + application = FastAPI() + application.include_router(version_module.router, prefix="/api/v1/trade-in") + return application + + +# ── GET /api/v1/trade-in/version ───────────────────────────────────────────── + + +def test_version_endpoint_shape(app: FastAPI) -> None: + client = TestClient(app) + r = client.get("/api/v1/trade-in/version") + assert r.status_code == 200 + body = r.json() + assert set(body.keys()) == {"version", "sha", "built_at"} + assert isinstance(body["version"], str) and body["version"] + assert isinstance(body["sha"], str) and body["sha"] + assert isinstance(body["built_at"], str) and body["built_at"] + + +def test_version_endpoint_matches_core_constants(app: FastAPI) -> None: + client = TestClient(app) + body = client.get("/api/v1/trade-in/version").json() + assert body["version"] == version_core.APP_VERSION + assert body["sha"] == version_core.BUILD_SHA + assert body["built_at"] == version_core.BUILD_DATE + + +def test_version_path_is_public_no_auth_required() -> None: + """rbac_guard must let this path through without X-Authenticated-User / + session — see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call.""" + from app.core.rbac import _PUBLIC_PATHS + + assert "/api/v1/trade-in/version" in _PUBLIC_PATHS + + +# ── app/core/version.py — product_version_line / format_build_date_human ──── + + +def test_product_version_line_format() -> None: + line = version_core.product_version_line("Мера") + assert line.startswith("Мера v") + parts = line.split(" · ") + assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}" + + +def test_format_build_date_human_parses_iso_utc() -> None: + assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026" + + +def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None: + assert version_core.format_build_date_human("not-a-date") == "not-a-date" + + +# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ────── +# (local `uvicorn` run without a Docker build — see module docstring in +# app/core/version.py). Reloading the module re-executes its module-level +# env reads; nothing here may raise. + + +def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_VERSION", raising=False) + monkeypatch.delenv("BUILD_SHA", raising=False) + monkeypatch.delenv("BUILD_DATE", raising=False) + + reloaded = importlib.reload(version_core) + + assert reloaded.BUILD_SHA == "dev" + assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default + assert reloaded.BUILD_DATE.endswith("Z") + # format/product helpers must still work off the fallback values (no crash). + assert reloaded.product_version_line("Мера").startswith("Мера v") + + # Reload once more so any test running later in this process sees a module + # state consistent with whatever env pytest was actually invoked under. + importlib.reload(version_core) diff --git a/tradein-mvp/backend/tests/test_yandex_detail.py b/tradein-mvp/backend/tests/test_yandex_detail.py index c3431aa5..f481529d 100644 --- a/tradein-mvp/backend/tests/test_yandex_detail.py +++ b/tradein-mvp/backend/tests/test_yandex_detail.py @@ -385,3 +385,42 @@ class TestHelpers: result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) assert result is not None assert result.metro_walk_min == 7 + + +# --------------------------------------------------------------------------- +# #2669 — просмотры с разделителем тысяч +# --------------------------------------------------------------------------- +# Форма взята дословно из сохранённого ответа Яндекса +# tests/fixtures/yandex_offer_3402418396407468801.html (сверено 2026-08-07): +#
16 июня, 8 просмотров35,5 м², ... +# 3 850 000 ₽108 451 ₽ за м²... +# — тот же блок, из которого читаются просмотры, отдаёт числа с NBSP-разделителем. +# Прежняя RE_VIEWS `(\d+)\s+просмотр` обрывалась на разделителе и писала в БД +# ПОСЛЕДНИЕ ТРИ ЦИФРЫ (1 234 → 234): не пусто, а тихо неверно. Косвенное +# подтверждение с прода на 2026-08-07: из 1620 непустых views_total_yandex +# ровно НОЛЬ значений ≥ 1000, при 2512 таких у avito. + + +@pytest.mark.parametrize( + ("views_fragment", "expected"), + [ + ("8\xa0просмотров", 8), # дословно из сохранённого ответа + ("1\xa0234 просмотра", 1234), # NBSP-разделитель — до правки давало 234 + ("1 234 просмотра", 1234), # обычный пробел + ("12\xa0345 просмотров", 12345), + ("342 просмотра", 342), # без разделителя — прежнее поведение цело + ], +) +def test_views_total_survives_thousands_separator(views_fragment: str, expected: int) -> None: + html = _make_html(summary_text=f"Екатеринбург, улица Малышева, д. 5 • {views_fragment}") + result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) + assert result is not None + assert result.views_total == expected, "просмотры Яндекса снова режутся по разделителю тысяч" + + +def test_views_total_absent_stays_none() -> None: + """Нет блока просмотров → None, а не 0: «не пришло» ≠ «ноль просмотров».""" + html = _make_html(summary_text="Екатеринбург, улица Малышева, д. 5 • нет данных") + result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) + assert result is not None + assert result.views_total is None diff --git a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py index 3bc90192..44d6ac26 100644 --- a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py +++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py @@ -28,6 +28,19 @@ from scraper_kit.providers.yandex.valuation import ( from app.services.estimator import _save_yandex_history_items +def _history_rows(db) -> list[dict]: + """Строки батча house_placement_history из мока сессии. + + #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция + сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama + перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + """ + for call in db.execute.call_args_list: + if "INSERT INTO house_placement_history" in str(call.args[0]): + return call.args[1] + return [] + + def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult: return YandexValuationResult( address="Россия, Свердловская область, Екатеринбург, ул. Куйбышева, 106", @@ -84,7 +97,7 @@ def test_item_with_area_none_is_skipped() -> None: saved = _save_yandex_history_items(db, result) assert saved == 1, f"Ожидали 1 сохранённый item, получили {saved}" - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert len(rows) == 1 assert rows[0]["area"] == 50.0 @@ -109,7 +122,7 @@ def test_item_with_area_zero_is_skipped() -> None: saved = _save_yandex_history_items(db, result) assert saved == 1 - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert rows[0]["area"] == 55.0 @@ -155,8 +168,11 @@ def test_all_invalid_area_returns_zero_no_crash() -> None: assert saved == 0 # db.execute не должен вызываться для пустого rows (нет INSERT) - # Commit вызывается, rollback — нет. - db.commit.assert_called_once() + assert _history_rows(db) == [] + # Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись + # houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась + # ли история по площади. + assert db.commit.call_count == 2 db.rollback.assert_not_called() @@ -200,7 +216,7 @@ def test_mixed_items_only_valid_saved() -> None: saved = _save_yandex_history_items(db, result) assert saved == 2 - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert len(rows) == 2 areas = {r["area"] for r in rows} assert areas == {40.0, 60.0} diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py index 9079f0df..308d4cb1 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation_save.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation_save.py @@ -26,6 +26,19 @@ from scraper_kit.providers.yandex.valuation import ( from app.services.estimator import _save_yandex_history_items +def _history_rows(db) -> list[dict]: + """Строки батча house_placement_history из мока сессии. + + #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция + сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama + перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + """ + for call in db.execute.call_args_list: + if "INSERT INTO house_placement_history" in str(call.args[0]): + return call.args[1] + return [] + + def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult: return YandexValuationResult( address="Россия, Свердловская область, Екатеринбург, улица Куйбышева, 106", @@ -90,9 +103,8 @@ def test_save_row_contains_house_id_and_confidence(): ): _save_yandex_history_items(db, result) - # db.execute called once with list-of-dicts (executemany, one round-trip) - assert db.execute.call_count == 1 - rows = db.execute.call_args_list[0].args[1] + # История — один execute со list-of-dicts (executemany, один round-trip). + rows = _history_rows(db) assert isinstance(rows, list) and len(rows) == 2 for row in rows: assert row["house_id"] == 54321 @@ -125,7 +137,7 @@ def test_save_row_contains_removed_date(): _save_yandex_history_items(db, result) # args[1] is now the list-of-dicts passed to executemany - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert rows[0]["removed_date"] == date(2024, 5, 20) @@ -152,7 +164,7 @@ def test_save_row_removed_date_none_when_active(): ): _save_yandex_history_items(db, result) - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert rows[0]["removed_date"] is None @@ -180,7 +192,7 @@ def test_save_handles_match_failure_gracefully(): saved = _save_yandex_history_items(db, result) assert saved == 1 - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert rows[0]["house_id"] is None assert rows[0]["confidence"] == pytest.approx(0.0) assert rows[0]["notes"] is None diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index bc97d927..ed630a90 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -47,6 +47,9 @@ Per-provider модель (#1793): для гидрации, CSS проверяется anti-bot fingerprint. (legacy alias BROWSER_BLOCKED_RESOURCE_TYPES всё ещё читается как fallback, если новая переменная не задана.) + ⚠ BROWSER_BLOCK_RESOURCES (булев выключатель до #1812) + НЕ читается — см. _RETIRED_ENV; в проде он всё ещё + выставлен во всех трёх контейнерах и ни на что не влияет. BROWSER_MIN_PAGE_INTERVAL_S — минимальный интервал (сек) между последовательными page.goto ОДНОГО провайдера (default: 2.0). Даёт под- коннектам предыдущей страницы дренироваться, прежде чем @@ -122,6 +125,11 @@ FETCH_JSON_SETTLE_MS: int = int(os.environ.get("FETCH_JSON_SETTLE_MS", "1200")) FETCH_JSON_INPAGE_RETRIES: int = int(os.environ.get("FETCH_JSON_INPAGE_RETRIES", "1")) # Пауза между in-page попытками fetch(), мс. FETCH_JSON_RETRY_DELAY_MS: int = int(os.environ.get("FETCH_JSON_RETRY_DELAY_MS", "800")) +# Сколько ждать события `load` на ПОВТОРЕ после гонки «execution context destroyed» +# (#2676). Только на повторе: happy-path остаётся на дешёвом FETCH_JSON_SETTLE_MS, +# иначе бесконечно дозагружающаяся страница удлиняла бы КАЖДЫЙ запрос. Ожидание +# best-effort — по таймауту всё равно пробуем evaluate. +FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15000")) # Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий # инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод. @@ -536,6 +544,36 @@ async def _on_startup(app: web.Application) -> None: list(PROVIDERS), ) logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) + _warn_retired_env() + + +# Переменные, которые окружение всё ещё задаёт, а код больше не читает. Ставим сюда +# ровно те, что реально видели в проде — молчаливо игнорируемая ручка опаснее +# отсутствующей: оператор ставит BROWSER_BLOCK_RESOURCES=false, чтобы посмотреть +# страницу с ресурсами, ничего не меняется, и он делает вывод не о переменной, +# а о блокировке. +_RETIRED_ENV: dict[str, str] = { + # #1812 заменил булев выключатель на список типов. Блокировка при этом НЕ + # ослабла: image глушится camoufox block_images (_launch_browser), font/media — + # дефолтом BROWSER_BLOCK_RESOURCE_TYPES. Мёртв только сам выключатель. + "BROWSER_BLOCK_RESOURCES": ( + "BROWSER_BLOCK_RESOURCE_TYPES (CSV типов; пустая строка = не блокировать)" + ), +} + + +def _warn_retired_env() -> None: + """Предупреждает про заданные env, которые код больше не читает (#2674).""" + for name, replacement in _RETIRED_ENV.items(): + if os.environ.get(name) is not None: + logger.warning( + "tradein-browser: %s=%r задана, но НЕ читается кодом с #1812 — " + "значение ни на что не влияет. Замена: %s. Уберите переменную из " + "окружения, чтобы она не выглядела рабочим выключателем", + name, + os.environ[name], + replacement, + ) async def _on_cleanup(app: web.Application) -> None: @@ -967,9 +1005,54 @@ async def _do_fetch_json( return await _fetch_json_once( provider, url, method=method, headers=headers, body=body, origin=origin ) + # #2676 обрабатывается ВНУТРИ _fetch_json_once (повтор evaluate на той же + # странице). Повторять здесь, на свежей странице, бесполезно: пересоздание + # воспроизводит ту же первую навигацию origin'а — проверено на проде 06.08, + # обе попытки упали одинаково. raise +# In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), #1917: +# первый XHR после навигации иногда падает до готовности стека. Повтор внутри страницы +# (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). HTTP-статусы (4xx/5xx) — +# это успешный resp, НЕ ретраим (решает caller). +_IN_PAGE_FETCH_JS = """async ({url, method, headers, body, retries, retryDelayMs}) => { + let lastErr; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const resp = await fetch(url, { + method: method || 'GET', + headers: headers || {}, + body: (body !== null && body !== undefined) ? body : undefined, + credentials: 'include', + }); + const text = await resp.text(); + return { status: resp.status, body: text, attempts: attempt + 1 }; + } catch (e) { + lastErr = e; + if (attempt < retries) { + await new Promise(r => setTimeout(r, retryDelayMs)); + } + } + } + throw lastErr; +}""" + + +async def _wait_for_load_best_effort(page: object, provider: str) -> None: + """Ждёт `load`, но не даёт ожиданию сорвать саму попытку (реклама/трекеры могут + держать страницу «загружающейся» бесконечно). Таймаут логируется, не пробрасывается. + """ + try: + await page.wait_for_load_state("load", timeout=FETCH_JSON_LOAD_WAIT_MS) # type: ignore[attr-defined] + except Exception as exc: + logger.info( + "tradein-browser[%s]: load не дождались (%s), пробуем evaluate как есть", + provider, + type(exc).__name__, + ) + + async def _fetch_json_once( provider: str, url: str, @@ -1001,41 +1084,34 @@ async def _fetch_json_once( # (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась # перед in-page fetch'ем. await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] - # In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), - # #1917: первый XHR после навигации иногда падает до готовности стека. Повтор - # внутри страницы (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). - # HTTP-статусы (4xx/5xx) — это успешный resp, НЕ ретраим (решает caller). - result: dict = await page.evaluate( # type: ignore[attr-defined] - """async ({url, method, headers, body, retries, retryDelayMs}) => { - let lastErr; - for (let attempt = 0; attempt <= retries; attempt++) { - try { - const resp = await fetch(url, { - method: method || 'GET', - headers: headers || {}, - body: (body !== null && body !== undefined) ? body : undefined, - credentials: 'include', - }); - const text = await resp.text(); - return { status: resp.status, body: text, attempts: attempt + 1 }; - } catch (e) { - lastErr = e; - if (attempt < retries) { - await new Promise(r => setTimeout(r, retryDelayMs)); - } - } - } - throw lastErr; - }""", - { - "url": url, - "method": method, - "headers": headers or {}, - "body": body, - "retries": FETCH_JSON_INPAGE_RETRIES, - "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, - }, - ) + payload = { + "url": url, + "method": method, + "headers": headers or {}, + "body": body, + "retries": FETCH_JSON_INPAGE_RETRIES, + "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, + } + try: + result: dict = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] + except Exception as exc: + if not _is_page_context_lost(exc): + raise + # #2676, живой прод 06.08: страница origin уходит в клиентскую навигацию + # ПОСЛЕ load, поэтому ни settle, ни ожидание load её не опережают, а повтор + # с новой страницей воспроизводит ту же первую навигацию (проверено на + # проде: две попытки подряд — та же ошибка). Навигация при этом ОДНА: + # к моменту исключения она уже случилась и у страницы НОВЫЙ контекст. + # Поэтому повторяем evaluate на ЭТОЙ же странице — стоит миллисекунды. + logger.warning( + "tradein-browser[%s]: контекст страницы пересоздан навигацией, " + "повтор evaluate на той же странице: %s", + provider, + url, + ) + await _wait_for_load_best_effort(page, provider) + await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] + result = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] finally: await page.close() # type: ignore[attr-defined] @@ -1073,6 +1149,23 @@ def _is_browser_crash(exc: BaseException) -> bool: ) +# Литерал playwright, а не строка из нашего лога: driver 1.60.0 (в образе сайдкара) +# бросает ровно «Execution context was destroyed» / «... , most likely because of a +# navigation.» — обе формы начинаются одинаково, поэтому хватает одного маркера. +# Проверено grep'ом по playwright/driver/package/lib/coreBundle.js в живом контейнере. +_PAGE_CONTEXT_LOST_MARKER = "execution context was destroyed" + + +def _is_page_context_lost(exc: BaseException) -> bool: + """Страница потеряла JS-контекст (ушла в навигацию между goto и evaluate), #2676. + + НЕ краш браузера: инстанс жив, потеряна одна страница. Поэтому обрабатывается + отдельно от _is_browser_crash — relaunch здесь стоил бы ~10-20с и тёплый профиль + (cookies/фингерпринт инстанса) ради браузера, с которым всё в порядке. + """ + return _PAGE_CONTEXT_LOST_MARKER in str(exc).lower() + + # ── login handler ────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 39f5eacf..2f1438eb 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -372,7 +372,11 @@ def test_resource_block_handler_degrades_to_continue(monkeypatch: pytest.MonkeyP def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: """Первый goto провайдера — без ожидания, но timestamp пишется.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + # Интервал задаём в _MIN_PAGE_INTERVAL_BY_PROVIDER — ИМЕННО его читает + # _pace_provider (per-provider override, #1812 follow-up). Модульный + # BROWSER_MIN_PAGE_INTERVAL_S там только фолбэк для провайдера вне словаря, + # так что патч глобала на известном провайдере не влияет ни на что. + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -386,7 +390,7 @@ def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> N def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) -> None: """Второй goto в пределах интервала → sleep на остаток (interval - elapsed).""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -415,7 +419,7 @@ def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.MonkeyPatch) -> None: """Если с прошлого goto прошло >= интервала → не спим.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "yandex", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -440,8 +444,8 @@ def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.Monkey def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None: - """BROWSER_MIN_PAGE_INTERVAL_S <= 0 → пейсинг выключен, timestamp не пишется.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0) + """Эффективный интервал провайдера <= 0 → пейсинг выключен, timestamp не пишется.""" + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 0.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -460,7 +464,8 @@ def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> No def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) -> None: """Timestamp независим по провайдерам — пейсинг avito не влияет на cian.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -485,5 +490,39 @@ def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) assert slept == [], "разные провайдеры не пейсят друг друга" +# ── #2674: отставные переменные окружения ──────────────────────────────────── +# BROWSER_BLOCK_RESOURCES стоит =true во всех трёх прод-контейнерах, а код перестал +# её читать в #1812 (булев выключатель → BROWSER_BLOCK_RESOURCE_TYPES). Блокировка при +# этом не ослабла (image глушит camoufox block_images, font/media — дефолт списка), но +# сама ручка мертва: выставив =false, оператор ничего не выключит и сделает вывод о +# блокировке, а не о переменной. Сервис обязан сказать это вслух на старте. + + +def test_retired_env_warns_when_set( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false") + with caplog.at_level("WARNING"): + server._warn_retired_env() + assert any("BROWSER_BLOCK_RESOURCES" in r.getMessage() for r in caplog.records), ( + "мёртвая переменная выставлена, а сервис молчит" + ) + + +def test_retired_env_silent_when_unset( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.delenv("BROWSER_BLOCK_RESOURCES", raising=False) + with caplog.at_level("WARNING"): + server._warn_retired_env() + assert caplog.records == [], "предупреждение без повода — это шум, а не сигнал" + + +def test_retired_env_does_not_change_blocking(monkeypatch: pytest.MonkeyPatch) -> None: + """Отставная переменная остаётся отставной: блокировкой правит только список типов.""" + monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false") + assert server._parse_block_types(None) == frozenset({"font", "media"}) + + async def _coro(value: Any) -> Any: return value diff --git a/tradein-mvp/browser/test_server_fetch_json.py b/tradein-mvp/browser/test_server_fetch_json.py index 0af86622..3e841cf4 100644 --- a/tradein-mvp/browser/test_server_fetch_json.py +++ b/tradein-mvp/browser/test_server_fetch_json.py @@ -67,6 +67,7 @@ class _FakePage: def __init__(self, evaluate_result: dict[str, Any]) -> None: self.goto_urls: list[str] = [] self.waits: list[int] = [] # записанные wait_for_timeout(ms) — settle-проверка #1917 + self.load_waits: list[int] = [] # wait_for_load_state("load", timeout=) — #2676 self.closed = 0 # evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы. self.evaluate = AsyncMock(return_value=evaluate_result) @@ -80,6 +81,9 @@ class _FakePage: async def wait_for_timeout(self, ms: int) -> None: self.waits.append(ms) + async def wait_for_load_state(self, state: str, timeout: int = 0) -> None: + self.load_waits.append(timeout) + async def close(self) -> None: self.closed += 1 @@ -365,3 +369,247 @@ def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch healthy_page.evaluate.assert_awaited_once() assert crashing_page.closed == 1 assert healthy_page.closed == 1 + + +# ── гонка «execution context was destroyed» (#2676) ─────────────────────────────── + + +def test_fetch_json_retries_evaluate_on_the_same_page(monkeypatch: pytest.MonkeyPatch) -> None: + """#2676 (живой прод 06.08): навигация УЖЕ случилась → повтор evaluate на той же странице. + + Ключевое отличие от внешнего ретрая: страница НЕ пересоздаётся. Пересоздание + воспроизводит ту же первую навигацию origin'а (проверено на проде: обе попытки + падали одинаково), а после исключения у страницы уже новый, стабильный контекст. + """ + page = _FakePage({"status": 200, "body": "ok"}) + page.evaluate = AsyncMock( + side_effect=[ + RuntimeError("Page.evaluate: Execution context was destroyed, " + "most likely because of a navigation."), + {"status": 200, "body": '{"recovered": true}'}, + ] + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242) + + result = asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + + assert result == {"status": 200, "body": '{"recovered": true}'} + assert page.evaluate.await_count == 2 + # Ни новой страницы, ни повторной навигации — только ожидание конца текущей. + assert browser.opened == 1 + assert page.goto_urls == ["https://www.avito.ru/"] + assert page.load_waits == [4242] + assert page.closed == 1 + + +def test_fetch_json_same_page_retry_happens_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Повтор evaluate ровно один: вторая та же ошибка уезжает наверх (там внешняя ветка).""" + message = "Execution context was destroyed" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError(message)) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + with pytest.raises(RuntimeError, match="Execution context was destroyed"): + asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + assert page.evaluate.await_count == 2 + assert page.closed == 1 + + +def test_fetch_json_no_same_page_retry_for_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Чужая ошибка evaluate не даёт второго прохода — повтор стоит денег.""" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError("boom")) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + with pytest.raises(RuntimeError, match="boom"): + asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + page.evaluate.assert_awaited_once() + + +def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Подменяет _relaunch_browser счётчиком — тест падает, если его всё-таки позвали.""" + calls: list[str] = [] + + async def _fake(provider: str) -> None: + calls.append(provider) + + monkeypatch.setattr(server, "_relaunch_browser", _fake) + return calls + + +# Обе формы, которые бросает playwright 1.60 (driver coreBundle.js) — короткая и полная. +@pytest.mark.parametrize( + "message", + [ + "Page.evaluate: Execution context was destroyed, most likely because of a navigation.", + "Execution context was destroyed", + ], +) +def test_do_fetch_json_recovers_from_destroyed_context( + monkeypatch: pytest.MonkeyPatch, message: str +) -> None: + """#2676: обе формы сообщения playwright распознаются и переживаются повтором. + + До правки такая ошибка не попадала ни в одну ветку восстановления (_is_browser_crash + матчит только закрытие цели/браузера/соединения) и уезжала наверх как 500. + """ + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=[RuntimeError(message), {"status": 200, "body": '{"recovered": true}'}] + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + relaunched = _no_relaunch(monkeypatch) + + result = asyncio.run( + server._do_fetch_json( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + + assert result == {"status": 200, "body": '{"recovered": true}'} + # Браузер живой — перезапускать его нельзя (тёплые cookies + ~10-20с). + assert relaunched == [] + # И новой страницы тоже нет: повтор идёт по уже перешедшему контексту. + assert browser.opened == 1 + assert page.evaluate.await_count == 2 + + +def test_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + """Ожидание load на повторе — best-effort: таймаут не отменяет саму попытку.""" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=[RuntimeError("Execution context was destroyed"), {"status": 200, "body": "ok"}] + ) + page.wait_for_load_state = AsyncMock( # type: ignore[method-assign] + side_effect=TimeoutError("Timeout 15000ms exceeded") + ) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + _no_relaunch(monkeypatch) + + result = asyncio.run( + server._do_fetch_json( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + assert result == {"status": 200, "body": "ok"} + + +def test_do_fetch_json_gives_up_after_one_context_retry(monkeypatch: pytest.MonkeyPatch) -> None: + """Повтор ровно один: вторая та же ошибка уезжает наверх, а не крутит цикл. + + Свежей страницы тут намеренно НЕТ: пересоздание воспроизводит ту же первую + навигацию origin'а (прод 06.08 — обе попытки упали одинаково), а страховкой + остаётся один повтор всего запроса на стороне клиента (BrowserFetcher). + """ + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=RuntimeError("Page.evaluate: Execution context was destroyed") + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + _no_relaunch(monkeypatch) + + with pytest.raises(RuntimeError, match="Execution context was destroyed"): + asyncio.run( + server._do_fetch_json( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + assert page.evaluate.await_count == 2 + assert browser.opened == 1 + + +def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Чужая ошибка НЕ ретраится — повтор невосстановимого жжёт бюджет прогона.""" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + _no_relaunch(monkeypatch) + + with pytest.raises(RuntimeError, match="ERR_PROXY_CONNECTION_FAILED"): + asyncio.run( + server._do_fetch_json( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + page.evaluate.assert_awaited_once() + + +def test_fetch_json_handler_500_carries_reason_in_body(monkeypatch: pytest.MonkeyPatch) -> None: + """Причина отказа остаётся в теле 500 — её читает _raise_for_sidecar_status (#2708).""" + message = "Execution context was destroyed" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError(message)) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + _no_relaunch(monkeypatch) + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + + response = asyncio.run( + server.fetch_json_handler( + _make_request({"url": "https://www.avito.ru/api/x", "source": "avito"}) + ) + ) + assert response.status == 500 + assert "Execution context was destroyed" in _json_body(response)["error"] diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py index 43208a73..13c8527d 100644 --- a/tradein-mvp/browser/test_server_smoke.py +++ b/tradein-mvp/browser/test_server_smoke.py @@ -89,7 +89,7 @@ def test_fetch_returns_503_when_browser_unavailable( Никакого реального camoufox-launch'а / direct-fetch. """ - async def _no_ensure(provider: str) -> bool: + async def _no_ensure(provider: str, proxy_override: str | None = None) -> bool: return False # прокси всё ещё недоступен → браузер остаётся None monkeypatch.setattr(server, "_ensure_browser", _no_ensure) @@ -111,7 +111,7 @@ def test_login_returns_503_when_browser_unavailable( ) -> None: """/login тоже отдаёт 503 когда инстанс не поднят (без credential-логирования).""" - async def _no_ensure(provider: str) -> bool: + async def _no_ensure(provider: str, proxy_override: str | None = None) -> bool: return False monkeypatch.setattr(server, "_ensure_browser", _no_ensure) @@ -193,7 +193,7 @@ def test_fetch_opens_and_closes_own_page(monkeypatch: pytest.MonkeyPatch) -> Non server._browsers["avito"] = _FakeBrowser(tracker) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) - async def _ensure(provider: str) -> bool: + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_ensure_browser", _ensure) @@ -221,7 +221,7 @@ def test_fetch_recycles_after_threshold(monkeypatch: pytest.MonkeyPatch) -> None relaunched["n"] += 1 server._page_counters[provider] = 0 - async def _ensure(provider: str) -> bool: + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_relaunch_browser", _fake_relaunch) diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index bb1e073d..c68d74c2 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -23,13 +23,51 @@ # ПЕРЕСОЗДАСТ ВСЕ tradein-контейнеры, включая scraper — прервёт бегущий sweep # (stop_grace_period 120s даёт unit'у до-checkpoint'иться). Допустимо, разово. # -# logging: json-file с ротацией (20m × 3 = ≤60M на сервис). До этого драйвер по -# умолчанию рос без границ. Общий anchor ниже. +# logging: journald (#2741). Было json-file 20m × 3 — оно решало только размер, +# и ценой того, что лог ЖИВЁТ В КОНТЕЙНЕРЕ: `docker rm` уносит его целиком. +# Деплоев ~20/сутки, скрейперы работают ночью, разбор идёт утром → окно жизни +# лога почти никогда не покрывает интересное (#2695, #2698, #2676 — три разбора +# подряд уперлись в «логов уже нет»). +# +# journald: демон отдаёт stdout/stderr в /var/log/journal (persistent, на хосте), +# запись переживает пересоздание контейнера и читается ПО ВРЕМЕНИ, а не «сколько +# осталось от последнего рестарта». +# +# КАК ЧИТАТЬ (проверено на проде 2026-08-06): +# docker logs tradein-scraper # как и раньше: только текущий контейнер +# # история через пересоздания — журнал принадлежит root, а deploy-юзер +# # gendesign состоит в docker, но НЕ в adm/systemd-journal, и sudo просит пароль, +# # поэтому голый `journalctl` у него выдаёт «No entries». Рабочий однострочник: +# docker run --rm -v /:/host:ro alpine chroot /host sh -c \ +# 'TZ=UTC journalctl -t tradein-scraper -o short-iso \ +# --since "2026-08-07 00:39" --until "2026-08-07 01:40"' +# # TZ=UTC — не украшение: `--since/--until` разбираются в ЛОКАЛЬНОМ времени хоста +# # (а он в +03), и флаг `--utc` на это НЕ влияет — он меняет только вывод. Без +# # TZ=UTC окно уезжает на 3 часа и молча отдаёт чужой кусок ночи. Проверено. +# # То же по метке контейнера: CONTAINER_NAME=tradein-scraper (-o cat -f для tail -f) +# Владельцу стоит разово выдать `sudo usermod -aG adm gendesign` — после этого +# journalctl работает напрямую, без docker-обёртки (host-config, не этот файл). +# +# Ротация: журнал общий на хост, режется самим journald по размеру (SystemMaxUse) +# и НЕ обнуляется пересозданием контейнера — то есть глубина истории меряется +# сутками, а не «сколько прошло с последнего деплоя» (это и был баг #2741/#2715: +# 60 МБ json-file при флуде прокручивались за минуты, а деплой обнулял и их). +# Жёсткий потолок по возрасту (MaxRetentionSec) — при желании host drop-in. +# Замер 2026-08-06: /var/log/journal = +# 2.3G за 103 дня (~22 МБ/сутки системных) при дефолтном потолке SystemMaxUse=4G; +# tradein добавляет единицы-десятки МБ/сутки (5.8k карточек за сутки по +# scrape_runs, ~153 Б/строку) → до вытеснения старого ещё недели, требуемые +# «сутки-двое» покрыты с запасом. Дисковый риск нулевой: 4G — это ПОТОЛОК, при +# его достижении journald сам удаляет старейшее. +# Ceiling: journald рейт-лимитит (дефолт 10000 сообщений / 30s на сервис) — при +# превышении в журнал попадёт «Suppressed N messages». Июльский флуд postgres +# (40 МБ за 6 часов ≈ 12 строк/с) от лимита в ~25 раз ниже, но если такое +# появится — это host-config (drop-in journald.conf.d), не этот файл. +# tag: имя контейнера вместо ID — SYSLOG_IDENTIFIER стабилен между пересозданиями. x-logging: &default-logging - driver: json-file + driver: journald options: - max-size: "20m" - max-file: "3" + tag: "{{.Name}}" services: browser: @@ -168,6 +206,21 @@ services: # Значение ДОЛЖНО совпадать с TRADEIN_INTERNAL_AUTH_SECRET в .env главного # (Caddy) стека. Читается также из backend/.env.runtime (env_file выше). TRADEIN_INTERNAL_AUTH_SECRET: "${TRADEIN_INTERNAL_AUTH_SECRET:-}" + # #2709. Дефолт в коде — redis://localhost:6379/0, и внутри контейнера это + # мгновенный ConnectionRefused (замер: 16.6 мс) → кэш не работал ни дня. + # ⚠️ ПОРЯДОК КРИТИЧЕН, и цена ошибки измерена тем же клиентом, что в + # app/services/cache.py (socket_timeout=2.0, socket_connect_timeout=2.0): + # localhost, отказ соединения 16.6 мс ← как было + # имя не резолвится (NXDOMAIN) 123.5 мс + # имя резолвится, но не отвечает 2003.7 мс ← вот эта яма + # get/set глотают исключение в logger.warning и возвращают промах, поэтому + # яма МОЛЧАЛИВАЯ: 2 с на GET + 2 с на SET на каждый запрос, без единой + # ошибки наверх. Отсюда правило: сначала связность, потом переменная. + # Здесь она задаётся только потому, что связность уже проверена на проде + # ДО этой строки (throwaway-redis на gendesign_shared, из tradein-backend: + # PING 19 мс, SET 0.35 мс, GET 0.31 мс) — см. описание PR. + # db1 — не 0 и не 2: 0 занят celery-брокером gendesign, 2 — glitchtip'ом. + REDIS_URL: "redis://gendesign-redis:6379/1" depends_on: browser: condition: service_started diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile index a78e5632..5def62b7 100644 --- a/tradein-mvp/frontend/Dockerfile +++ b/tradein-mvp/frontend/Dockerfile @@ -4,9 +4,13 @@ # ---- deps ---- FROM node:20-alpine AS deps WORKDIR /app -COPY package.json package-lock.json* ./ +# Лок ОБЯЗАТЕЛЕН (без `*`): `npm ci` без него падает, и это правильно — +# отсутствие лока раньше молча давало `npm install`, т.е. состав зависимостей +# определялся датой сборки (#2770). +COPY package.json package-lock.json ./ +# --legacy-peer-deps: React 19 peer-dep mismatches (как в соседнем frontend/). RUN --mount=type=cache,target=/root/.npm \ - npm install --legacy-peer-deps --no-audit --no-fund + npm ci --legacy-peer-deps --no-audit --no-fund # ---- builder ---- @@ -26,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_ENABLE_PREVIEW="" ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW +# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть +# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические +# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не +# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник +# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml, +# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для +# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже +# frontend-код, потребляющий эти env (Dockerfile сам файл не читает). +ARG NEXT_PUBLIC_APP_VERSION="" +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +ARG NEXT_PUBLIC_BUILD_SHA="" +ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA +ARG NEXT_PUBLIC_BUILD_DATE="" +ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE + +# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на +# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI +# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно +# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job +# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с +# остальным контекстом. Локальная сборка без этого шага CI просто не находит +# файл — readChangelog() уже умеет деградировать (пустая история), сам +# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже). COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build @@ -45,6 +72,13 @@ ENV NODE_ENV=production \ COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static +# /versions — Server Component, statically prerendered at `npm run build` +# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked +# into .next/standalone above. This is a defensive fallback ONLY, in case that +# page ever stops being static: glob (trailing `*`) makes it a no-op when the +# builder stage doesn't have the file either (local build without the CI +# pre-copy step, see builder stage comment above) — never fails the build. +COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./ USER node EXPOSE 3000 diff --git a/tradein-mvp/frontend/package-lock.json b/tradein-mvp/frontend/package-lock.json new file mode 100644 index 00000000..4cc83b4f --- /dev/null +++ b/tradein-mvp/frontend/package-lock.json @@ -0,0 +1,8641 @@ +{ + "name": "tradein-mvp-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tradein-mvp-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.50.0", + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.0.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.0.0", + "eslint-config-next": "^15.0.0", + "jsdom": "^25.0.1", + "typescript": "^5.9.3", + "vitest": "^2.1.9" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@next/env": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.23.tgz", + "integrity": "sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.23.tgz", + "integrity": "sha512-0KnCFpiWVIsbwBhByZ0uIcjYM5xqGrFzN2eOPbwru/wuy5Z1dmA+3gP+PRbi4gl1Ny7an66BqAM/NHkX/50rbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.23.tgz", + "integrity": "sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.23.tgz", + "integrity": "sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.23.tgz", + "integrity": "sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.23.tgz", + "integrity": "sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.23.tgz", + "integrity": "sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.23.tgz", + "integrity": "sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.23.tgz", + "integrity": "sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.23.tgz", + "integrity": "sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001807", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.23.tgz", + "integrity": "sha512-z4WcTXNqFHwMG4V8WHb2xrlEPJvwarZa+H/6CR28vxr53icRnQzGXviO11p748BwrMZGl52itdPRzZfzYo0SKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.23", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.23.tgz", + "integrity": "sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.23", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.23", + "@next/swc-darwin-x64": "15.5.23", + "@next/swc-linux-arm64-gnu": "15.5.23", + "@next/swc-linux-arm64-musl": "15.5.23", + "@next/swc-linux-x64-gnu": "15.5.23", + "@next/swc-linux-x64-musl": "15.5.23", + "@next/swc-win32-arm64-msvc": "15.5.23", + "@next/swc-win32-x64-msvc": "15.5.23", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", + "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tradein-mvp/frontend/package.json b/tradein-mvp/frontend/package.json index f71653ba..ef448501 100644 --- a/tradein-mvp/frontend/package.json +++ b/tradein-mvp/frontend/package.json @@ -8,6 +8,8 @@ "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs" }, "dependencies": { @@ -18,12 +20,18 @@ "recharts": "^2.15.4" }, "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@eslint/eslintrc": "^3.0.0", + "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.0.0", "eslint-config-next": "^15.0.0", - "typescript": "^5.9.3" + "jsdom": "^25.0.1", + "typescript": "^5.9.3", + "vitest": "^2.1.9" } } diff --git a/tradein-mvp/frontend/pnpm-lock.yaml b/tradein-mvp/frontend/pnpm-lock.yaml deleted file mode 100644 index 1eff3e29..00000000 --- a/tradein-mvp/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,3708 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@tanstack/react-query': - specifier: ^5.50.0 - version: 5.100.14(react@19.2.6) - next: - specifier: ^15.0.0 - version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: - specifier: ^19.0.0 - version: 19.2.6 - react-dom: - specifier: ^19.0.0 - version: 19.2.6(react@19.2.6) - recharts: - specifier: ^2.15.4 - version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - devDependencies: - '@eslint/eslintrc': - specifier: ^3.0.0 - version: 3.3.5 - '@types/node': - specifier: ^22.0.0 - version: 22.19.19 - '@types/react': - specifier: ^19.0.0 - version: 19.2.15 - '@types/react-dom': - specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.15) - eslint: - specifier: ^9.0.0 - version: 9.39.4 - eslint-config-next: - specifier: ^15.0.0 - version: 15.5.18(eslint@9.39.4)(typescript@5.9.3) - typescript: - specifier: ^5.9.3 - version: 5.9.3 - -packages: - - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@next/env@15.5.18': - resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} - - '@next/eslint-plugin-next@15.5.18': - resolution: {integrity: sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==} - - '@next/swc-darwin-arm64@15.5.18': - resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@15.5.18': - resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-linux-arm64-gnu@15.5.18': - resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-arm64-musl@15.5.18': - resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@next/swc-linux-x64-gnu@15.5.18': - resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-x64-musl@15.5.18': - resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@next/swc-win32-arm64-msvc@15.5.18': - resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-x64-msvc@15.5.18': - resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@nolyfill/is-core-module@1.0.39': - resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} - engines: {node: '>=12.4.0'} - - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - - '@rushstack/eslint-patch@1.16.1': - resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} - - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - - '@tanstack/query-core@5.100.14': - resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} - - '@tanstack/react-query@5.100.14': - resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} - peerDependencies: - react: ^18 || ^19 - - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-shape@3.1.8': - resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - - '@types/node@22.19.19': - resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.15': - resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} - - '@typescript-eslint/eslint-plugin@8.59.4': - resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.59.4 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.59.4': - resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.59.4': - resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.59.4': - resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.59.4': - resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.59.4': - resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.59.4': - resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.59.4': - resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.59.4': - resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.59.4': - resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@unrs/resolver-binding-android-arm-eabi@1.12.2': - resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} - cpu: [arm] - os: [android] - - '@unrs/resolver-binding-android-arm64@1.12.2': - resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} - cpu: [arm64] - os: [android] - - '@unrs/resolver-binding-darwin-arm64@1.12.2': - resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} - cpu: [arm64] - os: [darwin] - - '@unrs/resolver-binding-darwin-x64@1.12.2': - resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} - cpu: [x64] - os: [darwin] - - '@unrs/resolver-binding-freebsd-x64@1.12.2': - resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} - cpu: [x64] - os: [freebsd] - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': - resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': - resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': - resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-arm64-musl@1.12.2': - resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': - resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-loong64-musl@1.12.2': - resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': - resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': - resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': - resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': - resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-x64-gnu@1.12.2': - resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-x64-musl@1.12.2': - resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-openharmony-arm64@1.12.2': - resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} - cpu: [arm64] - os: [openharmony] - - '@unrs/resolver-binding-wasm32-wasi@1.12.2': - resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': - resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} - cpu: [arm64] - os: [win32] - - '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': - resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} - cpu: [ia32] - os: [win32] - - '@unrs/resolver-binding-win32-x64-msvc@1.12.2': - resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} - cpu: [x64] - os: [win32] - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axe-core@4.11.4: - resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} - engines: {node: '>=4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - caniuse-lite@1.0.30001793: - resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-format@3.1.2: - resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js-light@2.5.1: - resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - es-abstract@1.24.2: - resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-iterator-helpers@1.3.2: - resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-config-next@15.5.18: - resolution: {integrity: sha512-HuoJU6uUPD00eyiud78IBnT4HLhztFj2V+ild2Uon5ZUrYZKe0Olu2QRD99e9IgL4/H1eg5Onka3BsfRW2U0Xw==} - peerDependencies: - eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 - typescript: '>=3.3.1' - peerDependenciesMeta: - typescript: - optional: true - - eslint-import-resolver-node@0.3.10: - resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} - - eslint-import-resolver-typescript@3.10.1: - resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - eslint: '*' - eslint-plugin-import: '*' - eslint-plugin-import-x: '*' - peerDependenciesMeta: - eslint-plugin-import: - optional: true - eslint-plugin-import-x: - optional: true - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-jsx-a11y@6.10.2: - resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - - eslint-plugin-react-hooks@5.2.0: - resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-equals@5.4.0: - resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} - engines: {node: '>=6.0.0'} - - fast-glob@3.3.1: - resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} - engines: {node: '>= 0.4'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-bun-module@2.0.0: - resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} - engines: {node: '>= 0.4'} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - next@15.5.18: - resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - react-dom@19.2.6: - resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} - peerDependencies: - react: ^19.2.6 - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - react-smooth@4.0.4: - resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - - react@19.2.6: - resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} - engines: {node: '>=0.10.0'} - - recharts-scale@0.4.5: - resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} - - recharts@2.15.4: - resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} - engines: {node: '>=14'} - deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - resolve@2.0.0-next.7: - resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safe-array-concat@1.1.4: - resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} - engines: {node: '>=0.4'} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.8.1: - resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} - engines: {node: '>=10'} - hasBin: true - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stable-hash@0.0.5: - resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} - - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} - - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} - - string.prototype.repeat@1.0.0: - resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - styled-jsx@5.1.6: - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - unrs-resolver@1.12.2: - resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - victory-vendor@36.9.2: - resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - -snapshots: - - '@babel/runtime@7.29.2': {} - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': - dependencies: - eslint: 9.39.4 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.15.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@img/colour@1.1.0': - optional: true - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@next/env@15.5.18': {} - - '@next/eslint-plugin-next@15.5.18': - dependencies: - fast-glob: 3.3.1 - - '@next/swc-darwin-arm64@15.5.18': - optional: true - - '@next/swc-darwin-x64@15.5.18': - optional: true - - '@next/swc-linux-arm64-gnu@15.5.18': - optional: true - - '@next/swc-linux-arm64-musl@15.5.18': - optional: true - - '@next/swc-linux-x64-gnu@15.5.18': - optional: true - - '@next/swc-linux-x64-musl@15.5.18': - optional: true - - '@next/swc-win32-arm64-msvc@15.5.18': - optional: true - - '@next/swc-win32-x64-msvc@15.5.18': - optional: true - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@nolyfill/is-core-module@1.0.39': {} - - '@rtsao/scc@1.1.0': {} - - '@rushstack/eslint-patch@1.16.1': {} - - '@swc/helpers@0.5.15': - dependencies: - tslib: 2.8.1 - - '@tanstack/query-core@5.100.14': {} - - '@tanstack/react-query@5.100.14(react@19.2.6)': - dependencies: - '@tanstack/query-core': 5.100.14 - react: 19.2.6 - - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/d3-array@3.2.2': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-shape@3.1.8': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - - '@types/estree@1.0.9': {} - - '@types/json-schema@7.0.15': {} - - '@types/json5@0.0.29': {} - - '@types/node@22.19.19': - dependencies: - undici-types: 6.21.0 - - '@types/react-dom@19.2.3(@types/react@19.2.15)': - dependencies: - '@types/react': 19.2.15 - - '@types/react@19.2.15': - dependencies: - csstype: 3.2.3 - - '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.4 - eslint: 9.39.4 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.4 - debug: 4.4.3 - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.59.4(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) - '@typescript-eslint/types': 8.59.4 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.59.4': - dependencies: - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/visitor-keys': 8.59.4 - - '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.59.4': {} - - '@typescript-eslint/typescript-estree@8.59.4(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.59.4(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/visitor-keys': 8.59.4 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.1 - tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.59.4(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.59.4': - dependencies: - '@typescript-eslint/types': 8.59.4 - eslint-visitor-keys: 5.0.1 - - '@unrs/resolver-binding-android-arm-eabi@1.12.2': - optional: true - - '@unrs/resolver-binding-android-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.12.2': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-loong64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-openharmony-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.12.2': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': - optional: true - - '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': - optional: true - - '@unrs/resolver-binding-win32-x64-msvc@1.12.2': - optional: true - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array.prototype.findlast@1.2.5: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - es-shim-unscopables: 1.1.0 - - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-shim-unscopables: 1.1.0 - - array.prototype.tosorted@1.1.4: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - ast-types-flow@0.0.8: {} - - async-function@1.0.0: {} - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - axe-core@4.11.4: {} - - axobject-query@4.1.0: {} - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.9: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001793: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - client-only@0.0.1: {} - - clsx@2.1.1: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - concat-map@0.0.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - csstype@3.2.3: {} - - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-color@3.1.0: {} - - d3-ease@3.0.1: {} - - d3-format@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.2 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - - damerau-levenshtein@1.0.8: {} - - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - debug@3.2.7: - dependencies: - ms: 2.1.3 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js-light@2.5.1: {} - - deep-is@0.1.4: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - detect-libc@2.1.2: - optional: true - - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.29.2 - csstype: 3.2.3 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - emoji-regex@9.2.2: {} - - es-abstract@1.24.2: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.4 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-iterator-helpers@1.3.2: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-set-tostringtag: 2.1.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - iterator.prototype: 1.1.5 - math-intrinsics: 1.1.0 - - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.3 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - escape-string-regexp@4.0.0: {} - - eslint-config-next@15.5.18(eslint@9.39.4)(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 15.5.18 - '@rushstack/eslint-patch': 1.16.1 - '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4) - eslint-plugin-react: 7.37.5(eslint@9.39.4) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - - eslint-import-resolver-node@0.3.10: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.2 - resolve: 2.0.0-next.7 - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.4 - get-tsconfig: 4.14.0 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.16 - unrs-resolver: 1.12.2 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4) - transitivePeerDependencies: - - supports-color - - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4 - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4) - hasown: 2.0.3 - is-core-module: 2.16.2 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.4 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 9.39.4 - hasown: 2.0.3 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - - eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): - dependencies: - eslint: 9.39.4 - - eslint-plugin-react@7.37.5(eslint@9.39.4): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.3.2 - eslint: 9.39.4 - estraverse: 5.3.0 - hasown: 2.0.3 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.5 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.7 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - eventemitter3@4.0.7: {} - - fast-deep-equal@3.1.3: {} - - fast-equals@5.4.0: {} - - fast-glob@3.3.1: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - - function-bind@1.1.2: {} - - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.3 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - generator-function@2.0.1: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 - - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - gopd@1.2.0: {} - - has-bigints@1.1.0: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.3 - side-channel: 1.1.0 - - internmap@2.0.3: {} - - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-bun-module@2.0.0: - dependencies: - semver: 7.8.1 - - is-callable@1.2.7: {} - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.3 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-extglob@2.1.1: {} - - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-number@7.0.0: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@2.0.5: {} - - isexe@2.0.0: {} - - iterator.prototype@1.1.5: - dependencies: - define-data-property: 1.1.4 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - has-symbols: 1.1.0 - set-function-name: 2.0.2 - - js-tokens@4.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@1.0.2: - dependencies: - minimist: 1.2.8 - - jsx-ast-utils@3.3.5: - dependencies: - array-includes: 3.1.9 - array.prototype.flat: 1.3.3 - object.assign: 4.1.7 - object.values: 1.2.1 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - language-subtag-registry@0.3.23: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.23 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - lodash@4.18.1: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - math-intrinsics@1.1.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - - minimist@1.2.8: {} - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - napi-postinstall@0.3.4: {} - - natural-compare@1.4.0: {} - - next@15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@next/env': 15.5.18 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001793 - postcss: 8.4.31 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-jsx: 5.1.6(react@19.2.6) - optionalDependencies: - '@next/swc-darwin-arm64': 15.5.18 - '@next/swc-darwin-x64': 15.5.18 - '@next/swc-linux-arm64-gnu': 15.5.18 - '@next/swc-linux-arm64-musl': 15.5.18 - '@next/swc-linux-x64-gnu': 15.5.18 - '@next/swc-linux-x64-musl': 15.5.18 - '@next/swc-win32-arm64-msvc': 15.5.18 - '@next/swc-win32-x64-msvc': 15.5.18 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - node-exports-info@1.6.0: - dependencies: - array.prototype.flatmap: 1.3.3 - es-errors: 1.3.0 - object.entries: 1.1.9 - semver: 6.3.1 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.entries@1.1.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - possible-typed-array-names@1.1.0: {} - - postcss@8.4.31: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - punycode@2.3.1: {} - - queue-microtask@1.2.3: {} - - react-dom@19.2.6(react@19.2.6): - dependencies: - react: 19.2.6 - scheduler: 0.27.0 - - react-is@16.13.1: {} - - react-is@18.3.1: {} - - react-smooth@4.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - fast-equals: 5.4.0 - prop-types: 15.8.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - - react-transition-group@4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@babel/runtime': 7.29.2 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - - react@19.2.6: {} - - recharts-scale@0.4.5: - dependencies: - decimal.js-light: 2.5.1 - - recharts@2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - clsx: 2.1.1 - eventemitter3: 4.0.7 - lodash: 4.18.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts-scale: 0.4.5 - tiny-invariant: 1.3.3 - victory-vendor: 36.9.2 - - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - - resolve-from@4.0.0: {} - - resolve-pkg-maps@1.0.0: {} - - resolve@2.0.0-next.7: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - node-exports-info: 1.6.0 - object-keys: 1.1.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - safe-array-concat@1.1.4: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.8.1: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.1 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - optional: true - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - source-map-js@1.2.1: {} - - stable-hash@0.0.5: {} - - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - - string.prototype.includes@2.0.1: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - - string.prototype.matchall@4.0.12: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - regexp.prototype.flags: 1.5.4 - set-function-name: 2.0.2 - side-channel: 1.1.0 - - string.prototype.repeat@1.0.0: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.24.2 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - strip-bom@3.0.0: {} - - strip-json-comments@3.1.1: {} - - styled-jsx@5.1.6(react@19.2.6): - dependencies: - client-only: 0.0.1 - react: 19.2.6 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - tiny-invariant@1.3.3: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - tsconfig-paths@3.15.0: - dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tslib@2.8.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typescript@5.9.3: {} - - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - undici-types@6.21.0: {} - - unrs-resolver@1.12.2: - dependencies: - napi-postinstall: 0.3.4 - optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.12.2 - '@unrs/resolver-binding-android-arm64': 1.12.2 - '@unrs/resolver-binding-darwin-arm64': 1.12.2 - '@unrs/resolver-binding-darwin-x64': 1.12.2 - '@unrs/resolver-binding-freebsd-x64': 1.12.2 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 - '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 - '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 - '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 - '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-x64-musl': 1.12.2 - '@unrs/resolver-binding-openharmony-arm64': 1.12.2 - '@unrs/resolver-binding-wasm32-wasi': 1.12.2 - '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 - '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 - '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - victory-vendor@36.9.2: - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-ease': 3.0.2 - '@types/d3-interpolate': 3.0.4 - '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.8 - '@types/d3-time': 3.0.4 - '@types/d3-timer': 3.0.2 - d3-array: 3.2.4 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-scale: 4.0.2 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-timer: 3.0.1 - - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - yocto-queue@0.1.0: {} diff --git a/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx new file mode 100644 index 00000000..d82503dc --- /dev/null +++ b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx @@ -0,0 +1,111 @@ +/** + * #2766 — отказ по частоте на форме входа должен читаться как отказ по частоте. + * + * PR #2712/#2717 ввели 429 при насыщении проверок пароля. До этого любой не-401 + * падал в общую фразу «Не удалось войти…», а человек, которого притормозил + * лимит, читал её как проблему сети и долбил форму дальше. Здесь закреплено, + * что 429 даёт СВОЁ сообщение, и что оно не путается ни с «неверный пароль», + * ни с общей ошибкой. + * + * Подменяется `fetch`, а не `loginErrorMessage`: так в тесте работает настоящая + * `apiFetch` и настоящий `HTTPError` — то есть проверяется весь путь от кода + * ответа до текста на экране, а не одна функция в вакууме. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import LoginPage from "../page"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), +})); + +/** + * Ответ на POST /auth/login задаётся тестом; всё остальное (поллинг непрочитанных + * у виджета поддержки на этой же странице) отвечает пустым 200, чтобы фон не + * подмешивал свои ошибки в проверяемое состояние формы. + */ +function stubFetch(loginStatus: number, loginBody: unknown) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/auth/login")) { + return new Response(JSON.stringify(loginBody), { + status: loginStatus, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }), + ); +} + +function renderLogin() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +function submitCredentials() { + fireEvent.change(screen.getByLabelText("Логин"), { + target: { value: "praktika" }, + }); + fireEvent.change(screen.getByLabelText("Пароль"), { + target: { value: "hunter2" }, + }); + fireEvent.submit(screen.getByRole("form", { name: "Вход в Меру" })); +} + +describe("LoginPage — сообщение об ошибке входа", () => { + beforeEach(() => vi.unstubAllGlobals()); + afterEach(() => vi.unstubAllGlobals()); + + it("429 — сообщение про частоту попыток, а не про пароль и не общее", async () => { + stubFetch(429, { detail: "Too Many Requests" }); + renderLogin(); + submitCredentials(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Слишком много попыток. Попробуйте через несколько минут", + ); + expect(alert).not.toHaveTextContent("Неверный логин или пароль"); + expect(alert).not.toHaveTextContent("Не удалось войти"); + }); + + it("401 — сообщение про пароль (429-ветка не поглощает остальные коды)", async () => { + stubFetch(401, { detail: "Invalid credentials" }); + renderLogin(); + submitCredentials(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Неверный логин или пароль"); + expect(alert).not.toHaveTextContent("Слишком много попыток"); + }); + + it("500 — общая фраза (её текст не должен подменять собой 429)", async () => { + stubFetch(500, { detail: "boom" }); + renderLogin(); + submitCredentials(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Не удалось войти. Проверьте подключение и попробуйте ещё раз", + ); + }); + + it("до отправки формы сообщения об ошибке нет", async () => { + stubFetch(429, { detail: "Too Many Requests" }); + renderLogin(); + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 3e521b68..5e5c2937 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -92,6 +92,25 @@ export const LEGAL_ENTITY: { /** Внутренний маршрут страницы про обработку персональных данных. */ export const PRIVACY_PATH = "/mera-public/privacy"; +/** + * Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта + * после оплаты (`trade_in_estimates.retain_until`, migration 240) — НЕ срок + * действия самого расчёта (тот отдельный, `expires_at`, часы). + * + * ЕДИНСТВЕННОЕ место, где это число хардкодится на фронте — любой другой + * текст (оферта, экран после оплаты) обязан импортировать эту константу, а + * не писать «12 месяцев» заново (см. `test_paid_retention_text_consistency.py` + * — проверяет, что privacy-страница действительно использует эту константу, + * а не литерал). + * + * Источник истины на бэкенде — `settings.trade_in_paid_retention_days = 365` + * (`app/core/config.py`). 365 дней округляется до «12 месяцев» для + * человекочитаемого текста (12×30=360..12×31=372 — 365 попадает в диапазон); + * если бэкендовое число когда-нибудь изменится так, что «12» перестанет быть + * честным округлением — обновить оба места руками, тест это тоже проверяет. + */ +export const PAID_REPORT_RETENTION_MONTHS = 12; + // --------------------------------------------------------------------------- // География // --------------------------------------------------------------------------- diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx index 82b35a28..0cdd50cf 100644 --- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { LEGAL_ENTITY, + PAID_REPORT_RETENTION_MONTHS, PUBLIC_ESTIMATE_ENABLED, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL, @@ -32,11 +33,26 @@ import { safeUrl } from "@/lib/safeUrl"; * квалификация — не наше дело: заявка привязывается к конкретному расчёту * (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее * сохранённым адресом. Финальную формулировку даёт юрист. - * - «мы удалим ваш телефон и заявку». Механизма удаления в бэкенде НЕТ: - * ни `DELETE FROM trade_in_leads/trade_in_estimates` в коде, ни - * retention/erasure-джоба среди `app/tasks/**` (проверено grep'ом); - * `expires_at` применяется только на чтении. Обещать удаление до появления - * процедуры нельзя — это самое дорогое из обещаний. + * - «мы обещаем удалить всё вообще» без оговорок. После #2547 механизм + * удаления в бэкенде ЕСТЬ: `app/services/data_erasure.py` (ручное + * удаление по обращению — по estimate_id/телефону/Telegram chat id) и + * `app/tasks/purge_expired_trade_in_data.py` (автоматическое удаление по + * истечении срока хранения). Прежняя формулировка «механизма нет» стала + * неправдой и здесь больше не пишется. Оговорка, которая остаётся честной: + * копия сообщения в Telegram-группе поддержки этим механизмом не + * стирается (см. докстринг `data_erasure.py`) — сюда её не выносим + * (излишняя техническая деталь для публичной страницы), но это ограничение + * реальное и известное. + * - Срок хранения оплаченного отчёта (PR #2754, `retain_until` / + * `trade_in_paid_retention_days` в backend `config.py`): число месяцев + * ниже — константа `PAID_REPORT_RETENTION_MONTHS` из `../content.ts`, + * НЕ литерал здесь (см. докстринг константы — она единственный источник + * этого числа на фронте; `test_paid_retention_text_consistency.py` + * проверяет, что эта страница действительно её импортирует). Платёжного + * кода в этом PR нет — срок описан на будущее, вместе с честной правкой + * ниже про то, что удаление сегодня описывает установленный ПОРЯДОК, а + * не наблюдаемый на проде автоматический прогон (задача засеяна + * выключенной). * * Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока * расчёт выключен, адрес действительно не покидает браузер; после включения это @@ -136,10 +152,17 @@ export default function MeraPublicPrivacyPage() { ) : null}

- Автоматической кнопки «удалить мои данные» в сервисе пока нет, и мы не - обещаем то, чего не умеем: порядок и сроки удаления будут описаны в - утверждённой политике обработки, которая появится здесь до открытия - публичного доступа. + Самостоятельной кнопки «удалить мои данные» в интерфейсе пока нет, но + механизм удаления в сервисе есть: обращение в поддержку об удалении мы + разбираем вручную и физически стираем телефон, адрес и расчёт из базы, + а не просто помечаем запись. Помимо запроса, для каждого типа данных + установлен срок хранения, по истечении которого они подлежат + удалению. Если результат расчёта оплачен, ссылка на отчёт и + связанные с ним данные хранятся на нашей стороне{" "} + {PAID_REPORT_RETENTION_MONTHS} месяцев с даты оплаты, а затем + подлежат удалению так же, как и остальные данные — на файл, который + вы скачали себе, это не влияет: мы его не отзываем, не изменяем и не + имеем к нему доступа.

Оператор

diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts index eadedb6a..e441325a 100644 --- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -380,6 +380,7 @@ export const FIXTURE_SALES: SalesVsListingsResponse = { deals_with_listings: 5, linkage_rate_pct: 55.6, median_discount_pct: -6.2, + median_discount_explanation: null, data_quality: "house_linked", pairs: [ { diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx index bf603195..c7d0543a 100644 --- a/tradein-mvp/frontend/src/app/v2/layout.tsx +++ b/tradein-mvp/frontend/src/app/v2/layout.tsx @@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google"; import { SupportButton } from "@/components/trade-in/v2/SupportButton"; import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext"; import { pageBg } from "@/components/trade-in/v2/tokens"; +import { VersionFooter } from "@/components/trade-in/VersionFooter"; // Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled // (no package.json change). Cyrillic + latin so RU labels render correctly. @@ -53,6 +54,15 @@ export default function TradeInV2Layout({ products without the МЕРА brand that don't need a support link. */} + {/* Real build-version indicator (task: показать реальную версию + продукта «Мера» в вебе). Deliberately OUTSIDE SupportChatProvider — + it needs no chat context — but still scoped to this /v2 layout for + the same reason SupportButton is: other basePath routes + (/scrapers/**, /sale-share) are unrelated products without the + МЕРА brand. Portals to document.body itself (see VersionFooter.tsx + docstring), so its position in this tree only matters for mount + order, not DOM placement. */} +
); } diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 1476a7f7..f0164c23 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -20,6 +20,7 @@ import TopNav from "@/components/trade-in/v2/TopNav"; import HeroBar from "@/components/trade-in/v2/HeroBar"; import ParamsPanel from "@/components/trade-in/v2/ParamsPanel"; import ResultPanel from "@/components/trade-in/v2/ResultPanel"; +import { LowConfidenceBanner } from "@/components/trade-in/v2/LowConfidenceBanner"; import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary"; import { LeadForm } from "@/components/trade-in/v2/LeadForm"; import { Footer } from "@/components/trade-in/v2/Footer"; @@ -123,7 +124,7 @@ const brackets: { key: string; style: CSSProperties }[] = [ // Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there // is an estimate. Dashes — never the design fixtures (which would read as a fake // real report). -const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" }; +const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—", retainUntil: null }; const EMPTY_OBJECT: ObjectInfo = { address: "—", city: "", @@ -584,6 +585,18 @@ export default function TradeInV2Page() { // — no hydration drift, same reason the PDF control is gated behind `mounted`. const hasEstimate = mounted && estimate != null && !insufficient; + // fix (never-block estimate) — reliability/relaxations are optional on the + // wire (old/cached estimates predate the backend fields), default to the + // "nothing to disclose" values so a stale response never fabricates a + // warning. LowConfidenceBanner mounts above the result whenever the sample + // was thin (reliability !== "ok") or the backend had to relax the search to + // produce a price at all (relaxations.length > 0) — never on insufficient + // (no price at all — that stays InsufficientPanel, no banner to layer over). + const reliability = estimate?.reliability ?? "ok"; + const relaxations = estimate?.relaxations ?? []; + const showLowConfidenceBanner = + !insufficient && (reliability !== "ok" || relaxations.length > 0); + // ── Mapped presentation data (memoised so nav/drawer toggles don't recompute // geometry). ────────────────────────────────────────────────────────── const report = useMemo( @@ -652,7 +665,7 @@ export default function TradeInV2Page() { // TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the // authoritative monthly counter; fall back to the loaded history length, then - // to the fixture default (undefined) for unwired/pre-load states. + // to undefined (TopNav renders 0) for pre-load states. const reportsCount = quota.data?.used ?? history.data?.length; // Real user for the TopNav menu. undefined until /me resolves → TopNav shows @@ -772,12 +785,31 @@ export default function TradeInV2Page() { /> ); } else if (estimate && !insufficient && resultPanelData) { + // Banner is a sibling ABOVE ResultPanel, not a change to ResultPanel + // itself — the wrapper only replaces the direct grid child; ResultPanel's + // own markup/props are untouched from before this fix. middleContent = ( - +
+ {showLowConfidenceBanner && ( + + )} + +
); } else if (estimate && insufficient) { middleContent = ; diff --git a/tradein-mvp/frontend/src/app/versions/page.tsx b/tradein-mvp/frontend/src/app/versions/page.tsx new file mode 100644 index 00000000..7c82a2ab --- /dev/null +++ b/tradein-mvp/frontend/src/app/versions/page.tsx @@ -0,0 +1,120 @@ +// /versions (→ `/trade-in/versions` behind basePath) — «История версий». +// +// Server Component, deliberately NOT "use client": `readChangelog()` reads +// `tradein-mvp/CHANGELOG.md` off disk via `fs.readFileSync` at build/render +// time and gets statically embedded — no client-side fetch, no network hop +// (see src/lib/changelog.ts for the exact read/parse contract + a known +// build-context gap, flagged there). +// +// Auth: this route has NO guard of its own — it lives inside the same +// app-router segment as every other closed МЕРА page (history/, cache/, +// team/), so `app/layout.tsx`'s `` already gates it exactly +// like the rest of the product. No new RBAC path was added; whatever the +// backend `auth/roles.yaml` wildcard already allows for `/trade-in/**` +// covers this page too. +import type { Metadata } from "next"; +import Link from "next/link"; + +import "@/components/trade-in/trade-in.css"; +import { APP_VERSION, formatRuDate } from "@/lib/buildInfo"; +import { readChangelog } from "@/lib/changelog"; + +export const metadata: Metadata = { + title: "История версий — МЕРА", +}; + +export default function VersionsPage() { + const entries = readChangelog(); + + return ( +
+

+ ← К оценке +

+ +

+ История версий +

+

+ Текущая версия:{" "} + {APP_VERSION === "dev" ? "dev-сборка" : `v${APP_VERSION}`} +

+ + {entries.length === 0 ? ( +

+ История изменений пока не опубликована. +

+ ) : ( +
+ {entries.map((entry) => { + const isCurrent = entry.version === APP_VERSION; + return ( +
+
+

+ v{entry.version} + {isCurrent && ( + + текущая + + )} +

+
{formatRuDate(entry.date)}
+
+
+ {entry.sections.length === 0 ? ( +

+ Без описания изменений. +

+ ) : ( + entry.sections.map((section) => ( +
+

+ {section.title} +

+
    + {section.items.map((item, i) => ( +
  • + {item} +
  • + ))} +
+
+ )) + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx index a6549a3f..ceb10b56 100644 --- a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx @@ -15,8 +15,9 @@ import { export interface ScrapeRunFull { run_id: number; source: string; - run_type: string | null; status: string; + /** Джоб этого источника реально опрашивает отмену (бэкенд, scrape_runs.honors_cancel). */ + cancellable: boolean; params: Record | null; counters: Record | null; total_seen: number | null; @@ -32,40 +33,55 @@ interface RunsListResp { rows: ScrapeRunFull[]; } +interface RunSourcesResp { + sources: string[]; +} + // ── Hook ─────────────────────────────────────────────────────────────────── -const RUN_STATUS_ALL = ["", "running", "done", "failed", "cancelled", "zombie", "banned"] as const; +// "skipped" (#2658) — пропущенное расписание (нет кук / уже бежит / нет handler'а); +// translateStatus уже знает «пропущено», бейдж падает в нейтральный вариант. +const RUN_STATUS_ALL = [ + "", + "running", + "done", + "failed", + "cancelled", + "zombie", + "banned", + "skipped", +] as const; type RunStatusFilter = (typeof RUN_STATUS_ALL)[number]; -// "" means "all sources"; otherwise a specific source prefix (avito / cian / yandex) -const RUN_SOURCE_FILTERS = ["", "avito", "cian", "yandex"] as const; -type RunSourceFilter = (typeof RUN_SOURCE_FILTERS)[number]; +// #2674: список источников приходит из данных, а не из литерала. Раньше здесь +// стояли три площадки (avito/cian/yandex), а в scrape_runs 53 разных source и НИ +// ОДНОЙ строки с таким точным значением — каждый пункт фильтра давал пустую +// выдачу, а 76% прогонов (вся площадка Домклик в том числе) отфильтровать было +// нечем. Новый источник теперь появляется в списке сам. +function useScrapeRunSources() { + return useQuery({ + queryKey: ["scrape-run-sources"], + // Отдельный ключ (не зависит от фильтров) — иначе список схлопывался бы при + // каждой смене фильтра, пока летит запрос. + queryFn: () => + apiFetch("/api/v1/admin/scrape/runs/sources"), + staleTime: 60_000, + retry: 1, + }); +} -const SOURCE_FILTER_LABELS: Record = { - "": "Все", - avito: "Avito", - cian: "Cian", - yandex: "Yandex", -}; - -function useScraperRuns( - source: ScraperSource, - status: RunStatusFilter, - sourceFilter: RunSourceFilter, - limit = 20, -) { +// limit=50 (API допускает 200): при выдаче по всем источникам первые 20 строк по +// started_at — на три четверти сердцебиение proxy_healthcheck (1631 прогон из 3245), +// и часовой сбор мог не поместиться на страницу (#2674). +function useScraperRuns(status: RunStatusFilter, sourceFilter: string, limit = 50) { return useQuery({ - queryKey: ["scrape-runs", source, status, sourceFilter, limit], + queryKey: ["scrape-runs", status, sourceFilter, limit], queryFn: () => { const qs = new URLSearchParams({ limit: String(limit) }); - // When a specific sourceFilter is chosen, ignore the tab-level source - // and pass it verbatim as the ?source= param - if (sourceFilter) { - qs.set("source", sourceFilter); - } else { - // fallback: filter by the current tab provider - qs.set("source", source); - } + // Пусто = «Все», и это буквально все источники. Раньше пустой выбор молча + // подставлял source вкладки ("avito"), которого в данных не существует, — + // подпись «Все» показывала ноль прогонов (#2674). + if (sourceFilter) qs.set("source", sourceFilter); if (status) qs.set("status", status); return apiFetch( `/api/v1/admin/scrape/runs?${qs.toString()}`, @@ -175,16 +191,19 @@ interface RunsTableProps { export function RunsTable({ source }: RunsTableProps) { const [statusFilter, setStatusFilter] = useState(""); - const [sourceFilter, setSourceFilter] = useState(""); + const [sourceFilter, setSourceFilter] = useState(""); const qc = useQueryClient(); - const runsQ = useScraperRuns(source, statusFilter, sourceFilter); + const runsQ = useScraperRuns(statusFilter, sourceFilter); + const sourcesQ = useScrapeRunSources(); + // mark_cancelled(run_id) source-агностичен — отменяется тот прогон, что выбран + // в таблице, независимо от вкладки, с которой нажали. const cancelMut = useCancelCitySweep(source); function handleCancel(runId: number) { cancelMut.mutate(runId, { onSuccess: () => { void qc.invalidateQueries({ - queryKey: ["scrape-runs", source, statusFilter, sourceFilter], + queryKey: ["scrape-runs", statusFilter, sourceFilter], }); }, }); @@ -194,7 +213,7 @@ export function RunsTable({ source }: RunsTableProps) {

История прогонов

- Последние 20 прогонов. Автообновление каждые 8 сек. + Последние 50 прогонов по всем источникам. Автообновление каждые 8 сек.

{/* Source filter */} @@ -208,7 +227,7 @@ export function RunsTable({ source }: RunsTableProps) { + {sourcesQ.isError && ( + + список источников не загрузился — фильтр показывает только «Все» + + )}
{/* Status filter */} @@ -290,7 +315,10 @@ export function RunsTable({ source }: RunsTableProps) { # - Тип + {/* #2674: было «Тип» = run_type, а он у всех 3244 прогонов один + ('city_sweep' по DEFAULT) и подписывал так даже + proxy_healthcheck. Что бежало — говорит source. */} + Источник Статус Старт Финиш @@ -312,7 +340,7 @@ export function RunsTable({ source }: RunsTableProps) { color: "var(--fg-secondary, #5b6066)", }} > - {r.run_type ?? "sweep"} + {r.source} @@ -354,7 +382,9 @@ export function RunsTable({ source }: RunsTableProps) { )} - {r.status === "running" && ( + {/* cancellable — от бэкенда (#2674): у остальных источников + отмена поставила бы статус 'cancelled' работающей задаче. */} + {r.status === "running" && r.cancellable && (