From 26fe9e4d778c4954ef86bef605ca445af2b9b9b3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 21:34:36 +0300 Subject: [PATCH 01/60] =?UTF-8?q?chore(ci):=20=D0=B4=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BD=D0=B0=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9,=20ru?= =?UTF-8?q?ff-=D0=B3=D0=B5=D0=B9=D1=82=20=D0=B4=D0=BB=D1=8F=20=D0=9C=D0=95?= =?UTF-8?q?=D0=A0=D0=AB,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D0=BE=D0=B3=D0=BE=20wor?= =?UTF-8?q?kflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy.yml: добавлен auth/** в path-триггеры. app.core.auth кэширует roles.yaml на весь lifetime процесса (@lru_cache), а файл монтируется ТОЛЬКО в backend (не в worker, у него лишь ./data и ./reports) — без триггера правка ролей вступала бы в силу в случайный момент. - ci-tradein.yml: добавлен блокирующий шаг `ruff check` — гейт для tradein backend его не гонял вообще. Заодно почищены 3 срабатывания RUF059 (unused unpacked vars) в test_estimator_pure_units.py:381 по established convention (`_` уже используется в соседнем тесте того же файла). - Удалён мёртвый .github/workflows/ci.yml (Forgejo его не исполняет; GitHub mirror отстал на 500+ коммитов). Закомментированный postgres-services блок НЕ перенесён — обоснование, ради которого его хотели оставить как заготовку ("тестов против живой БД нет"), уже неверно: tests/sql/ реально гоняются против Postgres в ci.yml (#2745), и там намеренно НЕ используется `services:` — раннер поднимает job и service-контейнеры в сети хоста, где порт 5432 занят прод-базой (см. ci.yml:91-100). - README.md: убрана ложная привязка mypy к живому CI-гейту (только ruff + pytest + coverage gate ≥65%); убран stale-блок про .github/workflows/ — каталог после удаления ci.yml пуст. --- .forgejo/workflows/ci-tradein.yml | 11 ++- .forgejo/workflows/deploy.yml | 8 ++ .github/workflows/ci.yml | 91 ------------------- README.md | 6 +- .../tests/test_estimator_pure_units.py | 2 +- 5 files changed, 21 insertions(+), 97 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 5b228267..16e35a8b 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -183,13 +183,22 @@ jobs: restore-keys: | uv-tradein-${{ runner.os }}- - - name: Sync deps (incl. dev group — pytest) + - name: Sync deps (incl. dev group — pytest, ruff) # Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored # только старый backend/uv.lock) → --frozen детерминирован и зеркалит # Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root # вверх от cwd. run: uv sync --frozen + - name: Lint (ruff check) + # Правила выбраны в tradein-mvp/backend/pyproject.toml ([tool.ruff.lint] + # select = E F I B UP N RUF), но до этого шага их никто не гонял в CI — + # "дерево чистое" было непроверенным утверждением, а не гарантией. + # Версия ruff — та же, что в tradein-mvp/uv.lock (--frozen из шага выше), + # т.е. ровно то, что видит `uv sync --frozen` в Dockerfile. + # Blocking: любое нарушение → job RED (не декоративно). + run: uv run ruff check . + - name: Run pytest (tradein-mvp/backend) # БЕЗ deselect'ов — сьют гоняется целиком (#2722). # diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 7303d4cf..46b570b5 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -20,6 +20,14 @@ on: # деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы # до прода до следующего чужого коммита в backend/. - "ops/db-bootstrap/**" + # RBAC roles config (auth/roles.yaml, bind-mounted read-only ТОЛЬКО в backend — + # см. docker-compose.prod.yml; worker монтирует лишь ./data и ./reports). + # app.core.auth кэширует парсинг на весь lifetime процесса (@lru_cache) — без + # этого триггера правка ролей вступала бы в силу в случайный момент, только на + # следующий чужой деплой (`up -d --force-recreate --no-deps backend worker beat` + # ниже сбрасывает кэш перезапуском процесса; сам файл в образ не запекается, + # ребилда картинок для этого не нужно). + - "auth/**" workflow_dispatch: concurrency: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a2c3ed7e..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: CI - -on: - push: - branches: - - main - - 'feat/**' - - 'fix/**' - - 'refactor/**' - - 'chore/**' - - 'docs/**' - - 'perf/**' - - 'test/**' - - 'hotfix/**' - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - backend: - runs-on: ubuntu-latest - services: - postgres: - image: postgis/postgis:16-3.4 - env: - POSTGRES_DB: gendesign - POSTGRES_USER: gendesign - POSTGRES_PASSWORD: gendesign - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U gendesign" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - defaults: - run: - working-directory: backend - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - - - name: Set up Python - run: uv python install 3.12 - - - name: Install system deps for geo + WeasyPrint - run: | - sudo apt-get update - sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \ - libcairo2 libpango-1.0-0 libpangoft2-1.0-0 - - - name: Install Python deps - run: uv sync - - - name: Lint (ruff) - run: uv run ruff check . - - - name: Type check (mypy strict on core) - run: | - uv run mypy \ - app/services/generative \ - app/services/site_finder/scorer.py - - - name: Test (pytest) - run: uv run pytest -q - env: - DATABASE_URL: postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign - - frontend: - runs-on: ubuntu-latest - defaults: - run: - working-directory: frontend - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: frontend/package-lock.json - - run: npm ci || npm install - - run: npm run lint - - run: npm run type-check - - run: npm run build diff --git a/README.md b/README.md index 36f5925d..2a1acaa7 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,10 @@ docker-compose.prod.yml main стек (backend, frontend, postgres, redis, work docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную .forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026) - ├── ci.yml lint (ruff) + mypy + pytest на PR + ├── ci.yml lint (ruff) + pytest на PR ├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy ├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations) └── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне -.github/workflows/ (остаточные — только obsidian-стек на GitHub) - └── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap) ``` --- @@ -158,7 +156,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru **Forgejo Actions deploys** (self-hosted `git.gendsgn.ru`, мигрировано с GitHub Actions 16.05.2026): -- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале. +- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + pytest (coverage gate ≥65%). mypy strict в гейте не гоняется (доступен вручную — `uv run mypy app/services/generative app/services/site_finder/scorer.py`). Блокирует merge при провале. - [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`. - [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн). - [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)* diff --git a/tradein-mvp/backend/tests/test_estimator_pure_units.py b/tradein-mvp/backend/tests/test_estimator_pure_units.py index 1170e4c2..b2f64d2c 100644 --- a/tradein-mvp/backend/tests/test_estimator_pure_units.py +++ b/tradein-mvp/backend/tests/test_estimator_pure_units.py @@ -378,7 +378,7 @@ def test_corridor_clamp_above_corridor_tier_c_clamps() -> None: def test_corridor_clamp_inside_corridor_is_noop() -> None: # Эконом/комфорт: headline в коридоре (с учётом slack) → ничего не меняется. - new_ppm2, new_price, new_low, new_high, clamped = _clamp( + new_ppm2, _, _, _, clamped = _clamp( median_ppm2=140_000, corridor_high=130_000, count=20, tier="C" ) assert clamped is False From 6ab359a1275ac858e236b91f17a234355b746b91 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 15:30:06 +0500 Subject: [PATCH 02/60] =?UTF-8?q?fix(db):=20/dev/shm=20=D0=B1=D0=BE=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B3=D0=BE=20postgres=20=E2=80=94=2064=20=D0=9C?= =?UTF-8?q?=D0=91=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20Docker=20=D0=BD=D0=B0=201=20=D0=93=D0=B8=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Параллельные планы Постгреса размещают DSM-сегменты в /dev/shm (dynamic_shared_memory_type=posix). Контейнеру БД никто не задавал shm_size, поэтому там держалось умолчание Docker — 64 МБ, и запросы Объектива падали с psycopg.errors.DiskFull «could not resize shared memory segment». Текст ошибки называет НЕ тот ресурс: на разделе 28 ГБ свободно, кончался именно /dev/shm. Замеры на проде 2026-08-10 (не рекомендация из интернета): постоянный расход ~9.8 МиБ (DSA кумулятивной статистики pgstat) один параллельный ~15.4 МиБ (3 одновременных → 55.9 МиБ из 64) → 4-й одновременный запрос не влезает; ровно это и наблюдалось (6 отказов за 1.2 с из двух backend-процессов) Нижняя граница: 128 МиБ покрывают лишь ~7-8 одновременных, а потолок celery (--concurrency=8) плюс request-path даёт ~12 → минимум 256 МиБ. Верхняя: /dev/shm это tmpfs, страницы выделяются по факту, поэтому значение — потолок, а не резерв; на хосте 11 ГиБ RAM (свободно ~6), 2 ГиБ я бы не переходил. 1 ГиБ = ~65 таких запросов, ~5x запаса. Локальная проверка A/B (postgres:16, GUC как на проде, различается только --shm-size): 6 одновременных параллельных запросов 64m → 4 отказа, пик 57.3 МиБ (упёрлись в лимит) 1g → 0 отказов, пик 113.3 МиБ (реальный спрос вдвое выше 64 МиБ) Refs #2812 --- docker-compose.prod.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9e04bd6b..f12276f2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -78,6 +78,16 @@ services: image: postgis/postgis:16-3.4 logging: *default-logging restart: unless-stopped + # #2812: /dev/shm под dynamic_shared_memory_type=posix. Умолчание Docker — 64 МБ, + # и параллельные планы кладут туда свои DSM-сегменты. Прод-замер 2026-08-10: + # база постоянно держит ~9.8 МиБ (DSA кумулятивной статистики pgstat), один + # параллельный запрос Объектива берёт ~15.4 МиБ → 4-й одновременный не влезает + # в 64 МиБ и падает `DiskFull: could not resize shared memory segment`. Ровно это + # и случилось: 6 отказов за 1.2 с (market_metrics / sales_series / special_indices). + # 1 ГиБ = ~65 таких запросов; потолок celery (--concurrency=8) + request-path ≈ 12. + # tmpfs выделяется ПО ФАКТУ: значение — потолок, не резерв (0 Б до первого запроса). + # Rollback = убрать строку (снова 64 МиБ) + пересоздать контейнер. + shm_size: 1gb environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} From 1ff6699b958aab09e9e2d4a3ee8e1218a29e69f0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 18:51:02 +0000 Subject: [PATCH 03/60] =?UTF-8?q?feat(tradein/scheduler):=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D1=89=D0=B8=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=85=D0=B2=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D1=87=D0=B5=D0=BA=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BE=D1=80=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0=20(#930)=20?= =?UTF-8?q?(#2845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_930_scheduler_resume_checkpoint.py | 295 ++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 82 ++++- .../src/scraper_kit/orchestration/runs.py | 32 +- .../scraper_kit/orchestration/scheduler.py | 132 +++++++- .../src/scraper_kit/providers/avito/serp.py | 61 +++- .../src/scraper_kit/providers/cian/serp.py | 18 +- 6 files changed, 588 insertions(+), 32 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py diff --git a/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py new file mode 100644 index 00000000..cc0ba37a --- /dev/null +++ b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py @@ -0,0 +1,295 @@ +"""#930 добивка: планировщик не подхватывал чекпоинт оборванного прогона. + +#930 сделал обе половины механизма — запись точки (`counters.done_buckets`, per-bucket +heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое), — +но единственным входом оставил админку. У avito full-load админского эндпоинта нет +вовсе, а планировщик передавал `resume_run_id=None` ЛИТЕРАЛОМ (scheduler.py 708/728/799 +на origin/main). То есть боевой путь возобновления не существовал ни одного дня. + +Цена на проде (замер 2026-08-12, 90 суток, read-only): 433 корзины в 30 оборванных +прогонах с ЖИВОЙ незабранной точкой — avito_full_load 242, cian_full_load 134, +avito_full_load_exhaustive 57. Прогон 3547 (09.08, убит деплоем на третьем часу, 35 из +84 корзин дерева) лежит до сих пор и будет подхвачен расписанием 139 16.08. + +Красный прогон на origin/main: + 1. `test_scheduler_hands_checkpoint_to_pipeline` — планировщик отдаёт в пайплайн + resume_run_id=None вместо id прошлого прогона (AssertionError на 3 источниках); + 2. `test_partial_bucket_is_not_complete` — бакет с выпавшей страницей приезжает в + on_bucket неотличимым от целого (у колбэка нет аргумента полноты вообще); + 3. `test_pipeline_keeps_partial_bucket_out_of_checkpoint` — TypeError: `_on_bucket` + на main принимает два аргумента, признаку полноты некуда приехать. +Тесты ладдера (`_resume_decision`) на main падают с AttributeError — функции нет. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import scheduler as sched +from scraper_kit.orchestration.pipeline import run_avito_full_load +from scraper_kit.providers.avito.serp import AvitoScraper + +PFX = "scraper_kit.orchestration.pipeline" + +# Прод-слепок расписания 139 (avito_full_load_exhaustive) на 2026-08-12: params прогона +# 3547 совпадают с default_params расписания байт-в-байт — это и есть «то же задание». +_PARAMS = { + "concurrency": 1, + "interval_days": 7, + "secondary_only": True, + "request_delay_sec": 7.0, + "price_cap_per_bucket": 1400, +} + + +def _candidate(**over: Any) -> SimpleNamespace: + """Строка-кандидат из _RESUME_CANDIDATE_SQL: прогон 3547 как он лежит на проде.""" + base = { + "prev_id": 3547, + "prev_status": "cancelled", + "prev_counters": { + "unique_fetched": 5496, + "done_buckets": [f"room_1_komn:{i}:0" for i in range(35)], + }, + "same_params": True, + "age_h": 164.6, # 6.86 суток — столько будет точке 3547 к подхвату 16.08 + "interval_days": "7", + } + base.update(over) + return SimpleNamespace(**base) + + +class _FakeDb: + """Двойник сессии: отдаёт ОДНУ строку-кандидата на любой SELECT, глотает UPDATE.""" + + def __init__(self, row: Any) -> None: + self.row = row + self.written: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: # update_heartbeat пишет вердикт + self.written.append(params) + return MagicMock() + return MagicMock(fetchone=lambda: self.row) + + def commit(self) -> None: + pass + + +# ── 1. Главное: планировщик обязан отдать точку в пайплайн ─────────────────── + + +@pytest.mark.parametrize( + ("job", "pipeline_fn"), + [ + (sched._job_avito_full_load, "run_avito_full_load"), + (sched._job_avito_full_load_exhaustive, "run_avito_full_load"), + (sched._job_cian_full_load, "run_cian_full_load"), + ], +) +async def test_scheduler_hands_checkpoint_to_pipeline(job: Any, pipeline_fn: str) -> None: + """Оборванный прогон с валидной точкой → новый прогон продолжает его, а не с нуля. + + Падает на origin/main: планировщик передаёт литеральный None — 433 корзины за 90 + суток перебирались заново, включая 35 корзин прогона 3547. + """ + db = _FakeDb(_candidate()) + captured: dict[str, Any] = {} + + async def _spy(*_a: Any, **kw: Any) -> None: + captured.update(kw) + + with patch.object(sched, pipeline_fn, _spy): + await job(db, 4000, dict(_PARAMS), MagicMock()) + + assert captured["resume_run_id"] == 3547 + + +async def test_verdict_lands_in_counters_of_new_run() -> None: + """Подхватили или нет — видно В СЧЁТЧИКАХ прогона, а не только в docker-логах. + + Логи теряются при редеплое (контейнер tradein-scraper пересоздаётся), поэтому + молчаливый отказ подхватить неотличим от отсутствия правки. + """ + db = _FakeDb(_candidate(prev_status="zombie")) + with patch.object(sched, "run_avito_full_load", AsyncMock()): + await sched._job_avito_full_load(db, 4000, dict(_PARAMS), MagicMock()) + + assert db.written, "вердикт о подхвате не записан в counters нового прогона" + written = db.written[-1]["counters"] + assert '"resume_reason": "status_zombie"' in written + assert '"resume_candidate": 3547' in written + + +# ── 2. Ладдер отказов: у каждого нуля своя причина ─────────────────────────── + + +@pytest.mark.parametrize( + ("row", "reason"), + [ + (None, "no_prev_run"), + (_candidate(prev_status="done"), "status_done"), + (_candidate(prev_status="zombie"), "status_zombie"), + (_candidate(same_params=False), "params_changed"), + (_candidate(prev_counters={"unique_fetched": 2977}), "no_checkpoint"), + (_candidate(age_h=200.0), "checkpoint_stale"), + (_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}), "chain_limit"), + ], +) +def test_resume_refusals_are_named(row: Any, reason: str) -> None: + """«Не подхватили» — это семь РАЗНЫХ фактов, и в counters они различимы.""" + resume_id, verdict = sched._resume_decision(row) + assert resume_id is None + assert verdict["resume_reason"] == reason + assert verdict["resume_from"] is None + + +def test_resume_chain_is_bounded() -> None: + """Цепочка возобновлений считается и упирается в потолок, а не тянется вечно. + + Потолок выведен из STALE_DIGEST_INTERVAL_FACTOR (см. scheduler.py): полный обход + обязан начаться раньше, чем сводка объявит источник просроченным. + """ + assert sched._MAX_RESUME_CHAIN == sched.STALE_DIGEST_INTERVAL_FACTOR - 1 + _id, first = sched._resume_decision(_candidate()) + assert first["resume_chain"] == 1 + _id2, second = sched._resume_decision( + _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 1}) + ) + assert second["resume_chain"] == sched._MAX_RESUME_CHAIN + third_id, third = sched._resume_decision( + _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}) + ) + assert third_id is None and third["resume_reason"] == "chain_limit" + + +def test_stale_threshold_follows_the_source_tick() -> None: + """Срок годности точки считается от такта ИСТОЧНИКА, а не общей константой. + + cian ходит раз в 3 суток, avito — раз в 7; одна и та же точка возрастом 100 ч для + первого просрочена, для второго свежая. Плюс сутки — сетка запуска (см. + _resume_decision): 164.6 ч прогона 3547 при такте 7 суток обязаны пройти, иначе + точку отвергал бы jitter расписания, а пропущенный цикл (13 суток) — нет. + """ + assert sched._resume_decision(_candidate(age_h=100.0, interval_days="3"))[0] is None + assert sched._resume_decision(_candidate(age_h=100.0, interval_days="7"))[0] == 3547 + assert sched._resume_decision(_candidate(age_h=164.6, interval_days="7"))[0] == 3547 + assert sched._resume_decision(_candidate(age_h=13 * 24.0, interval_days="7"))[0] is None + + +# ── 3. Недособранный бакет не имеет права попасть в чекпоинт ───────────────── + + +def _serp_config() -> SimpleNamespace: + return SimpleNamespace( + scraper_fetch_mode="curl_cffi", + browser_http_endpoint="http://browser.test/fetch", + scraper_proxy_url=None, + avito_proxy_max_rotations=0, + avito_serp_ok_not_banned=True, + avito_proxy_rotate_settle_s=0.0, + proxy_rotate_attempts=1, + proxy_rotate_attempt_timeout_s=1.0, + scraper_skip_seen_today=False, + ) + + +@pytest.mark.parametrize( + ("page2_html", "expected_complete"), + [(None, False), ("", True)], +) +async def test_partial_bucket_is_not_complete( + page2_html: str | None, expected_complete: bool +) -> None: + """Страница 2 из 3 выпала → бакет НЕ «сделан»; все три пришли → «сделан». + + Контрольная половина обязательна: реализация «всегда False» тоже прошла бы + одностороннюю проверку, но убила бы возобновление целиком. + + Падает на origin/main: `on_bucket` вызывается двумя аргументами, признака полноты + в протоколе нет — частичный бакет неотличим от целого и попадает в done_buckets. + """ + scraper = AvitoScraper(_serp_config()) + scraper.request_delay_sec = 0.0 + calls: list[tuple[str, bool]] = [] + + def _on_bucket(key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg] + calls.append((key, complete)) + + async def _fetch_page(_self: Any, _slug: str, page: int, *_a: Any, **_k: Any) -> str | None: + return page2_html if page == 2 else f"" + + with ( + patch.object(AvitoScraper, "_fetch_rooms_page_html", _fetch_page), + patch.object( + AvitoScraper, + "_parse_html", + lambda _self, html, **_k: [MagicMock(source_id=html, listing_segment="secondary")], + ), + ): + await scraper._paginate_leaf_bucket( + room_slug="kvartiry_1_komnatnye", + room_label="room_1_komn", + lo=0, + hi=3999999, + html="", + max_pages=3, + seen={}, + price_cap_per_bucket=1400, + max_pages_per_bucket=100, + concurrency=2, + secondary_only=False, + on_bucket=_on_bucket, + skip_buckets=None, + expected_total=3 * 50, + ) + + assert [c[1] for c in calls] == [expected_complete] + + +async def test_pipeline_keeps_partial_bucket_out_of_checkpoint() -> None: + """Пайплайн: лоты частичного бакета СОХРАНЕНЫ, но в чекпоинт он не попал. + + Именно здесь «видимая потеря» (перескрап) не превращается в «невидимую»: пропустить + частичный бакет на следующем прогоне значит не перечитать его страницы уже никогда. + """ + finals: list[dict[str, Any]] = [] + + class _Recorder: + def is_cancelled(self, *_a: Any, **_k: Any) -> bool: + return False + + def update_heartbeat(self, *_a: Any, **_k: Any) -> None: + pass + + def mark_done(self, _db: Any, _rid: int, counters: dict[str, Any]) -> None: + finals.append(dict(counters)) + + async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None: + on_bucket("room_1_komn:0:3999999", [MagicMock(source_id="a1")], True) + on_bucket("room_1_komn:4000000:4999999", [MagicMock(source_id="a2")], False) + + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_all_secondary = _fetch + + with ( + patch(f"{PFX}.AvitoScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))), + patch(f"{PFX}.runs", _Recorder()), + ): + counters = await run_avito_full_load( + MagicMock(), run_id=1, config=_serp_config(), matcher=MagicMock() + ) + + assert finals[0]["done_buckets"] == ["room_1_komn:0:3999999"] + assert finals[0]["partial_buckets"] == 1 + assert counters.unique_fetched == 2, "лоты частичного бакета обязаны быть сохранены" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 5c090d75..1f3bdd1a 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -2953,6 +2953,11 @@ class CianFullLoadCounters: detail_enriched: int = 0 detail_failed: int = 0 errors_count: int = 0 + # Бакеты, отданные SERP-слоем как НЕполные (страница выпала / исключение + # проглочено / hard-cap): лоты сохранены, но в done_buckets бакет не попал. + # Без этого счётчика «сколько бакетов чекпоинт не покрывает» видно только грепом + # логов, которые теряются при редеплое. + partial_buckets: int = 0 def to_dict(self) -> dict[str, int]: return {f.name: getattr(self, f.name) for f in fields(self)} @@ -3007,8 +3012,32 @@ async def run_cian_full_load( done: set[str] = set(skip_set) # накапливаем завершённые бакеты этого прогона - def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] - """Инкрементальный save после каждого leaf-бакета. Дописывает bucket_key в done.""" + def _mark_bucket(bucket_key: str, complete: bool) -> None: + """В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем. + + Ключ done_buckets ("room:lo:hi") одинаков у целого и у недособранного бакета, + поэтому признак полноты приезжает отдельным аргументом из SERP-слоя. Пропуск + частичного бакета на следующем прогоне означал бы, что его непрочитанные + страницы не перечитает уже никто, а счётчики покажут успех. + """ + if complete: + done.add(bucket_key) + return + counters.partial_buckets += 1 + logger.warning( + "cian-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, " + "следующий прогон перечитает его целиком (partial_buckets=%d)", + run_id, + bucket_key, + counters.partial_buckets, + ) + + def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg] + """Инкрементальный save после каждого leaf-бакета. + + complete=False → лоты сохраняем, бакет в чекпоинт не пишем + (см. _mark_bucket). Дефолт True — для вызывающих без пагинации. + """ nonlocal done if runs.is_cancelled(db, run_id): logger.info("cian-full-load run_id=%d: cancel detected in on_bucket", run_id) @@ -3023,7 +3052,7 @@ async def run_cian_full_load( ) raise RuntimeError("shutdown") if not lots: - done.add(bucket_key) + _mark_bucket(bucket_key, complete) runs.update_heartbeat( db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} ) @@ -3042,7 +3071,7 @@ async def run_cian_full_load( counters.saved_inserted += inserted counters.saved_updated += updated counters.unique_fetched += len(lots) - done.add(bucket_key) + _mark_bucket(bucket_key, complete) runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) logger.info( "cian-full-load run_id=%d: bucket %s saved ins=%d upd=%d total_unique=%d", @@ -3225,7 +3254,8 @@ async def run_cian_full_load( except NoProxyAvailableError as exc: # #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан - # сохранять done_buckets, а generic-RuntimeError → mark_failed его теряет. + # сохранять done_buckets, а generic-RuntimeError уходил в mark_failed, который + # его терял (теперь counters мержатся — runs.update_heartbeat/mark_*, #930). logger.error("cian-full-load run_id=%d: no proxy available — %s", run_id, exc) counters.errors_count += 1 runs.mark_banned( @@ -3467,7 +3497,8 @@ async def run_yandex_full_load( except NoProxyAvailableError as exc: # #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан - # сохранять done_buckets, а generic-RuntimeError → mark_failed его теряет. + # сохранять done_buckets, а generic-RuntimeError уходил в mark_failed, который + # его терял (теперь counters мержатся — runs.update_heartbeat/mark_*, #930). logger.error("yandex-full-load run_id=%d: no proxy available — %s", run_id, exc) counters.errors_count += 1 runs.mark_banned( @@ -3527,6 +3558,8 @@ class AvitoFullLoadCounters: saved_inserted: int = 0 saved_updated: int = 0 errors_count: int = 0 + # Бакеты, отданные SERP-слоем как НЕполные — см. CianFullLoadCounters. + partial_buckets: int = 0 def to_dict(self) -> dict[str, int]: return {f.name: getattr(self, f.name) for f in fields(self)} @@ -3599,8 +3632,32 @@ async def run_avito_full_load( ) done: set[str] = set(skip_set) - def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] - """Инкрементальный save после каждого leaf-бакета.""" + def _mark_bucket(bucket_key: str, complete: bool) -> None: + """В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем. + + Ключ done_buckets ("room:lo:hi") одинаков у целого и у недособранного бакета, + поэтому признак полноты приезжает отдельным аргументом из SERP-слоя. Пропуск + частичного бакета на следующем прогоне означал бы, что его непрочитанные + страницы не перечитает уже никто, а счётчики покажут успех. + """ + if complete: + done.add(bucket_key) + return + counters.partial_buckets += 1 + logger.warning( + "avito-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, " + "следующий прогон перечитает его целиком (partial_buckets=%d)", + run_id, + bucket_key, + counters.partial_buckets, + ) + + def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg] + """Инкрементальный save после каждого leaf-бакета. + + complete=False → лоты сохраняем, бакет в чекпоинт не пишем + (см. _mark_bucket). Дефолт True — для вызывающих без пагинации. + """ nonlocal done if runs.is_cancelled(db, run_id): logger.info("avito-full-load run_id=%d: cancel detected in on_bucket", run_id) @@ -3613,7 +3670,7 @@ async def run_avito_full_load( ) raise RuntimeError("shutdown") if not lots: - done.add(bucket_key) + _mark_bucket(bucket_key, complete) runs.update_heartbeat( db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} ) @@ -3632,7 +3689,7 @@ async def run_avito_full_load( counters.saved_inserted += inserted counters.saved_updated += updated counters.unique_fetched += len(lots) - done.add(bucket_key) + _mark_bucket(bucket_key, complete) runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) logger.info( "avito-full-load run_id=%d: bucket=%s saved ins=%d upd=%d total_unique=%d", @@ -3699,9 +3756,10 @@ async def run_avito_full_load( except NoProxyAvailableError as exc: # #2687: пул опустел mid-run. Ветка стоит ДО generic-RuntimeError намеренно — # NoProxyAvailableError его подкласс, и без неё отказ уходил в mark_failed, - # который (в отличие от mark_banned) НЕ пишет done_buckets. То есть чекпоинт + # который (в отличие от mark_banned) НЕ передавал done_buckets. То есть чекпоинт # терялся ровно на НАШЕМ отказе — том исходе, для которого #2686 требовал его - # сохранять наравне с блокировкой площадкой. + # сохранять наравне с блокировкой площадкой. Диагноз ban_kind='infra' ветка + # даёт по-прежнему; сам чекпоинт с #930-мержем counters переживает и mark_failed. logger.error("avito-full-load run_id=%d: no proxy available — %s", run_id, exc) counters.errors_count += 1 runs.mark_banned( diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index c0996e1b..86729701 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -100,8 +100,8 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int: # РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый # статус» и «явное поле причины»: # 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed -# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив -# статус, пришлось бы дублировать её в каждом потребителе. +# его тогда терял) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; +# расщепив статус, пришлось бы дублировать её в каждом потребителе. # 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых # молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы, # IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results), @@ -570,12 +570,21 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = return int(row.id) -def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None: - """UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки. +def update_heartbeat(db: Session, run_id: int, counters: dict[str, Any]) -> None: + """UPDATE heartbeat_at + counters (МЕРЖ, не замена) + total_seen/new_count колонки. total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926). COALESCE: если ключа нет в counters — старое значение колонки сохраняется. + + `counters || :counters` вместо замены (#930 добивка): чекпоинт `done_buckets` + писали ТОЛЬКО сайты, знающие о нём (`_on_bucket`), а heartbeat'ы, которые о нём не + знают, целиком перезаписывали объект и СТИРАЛИ точку. На проде это давало + немонотонную точку: `_on_progress` (после каждой комнатности) и фоновый heartbeat + cian'а (каждые 60 с) отправляли `counters.to_dict()` без ключа — то есть у cian + точка в БД жила лишь от сохранения бакета до ближайшего тика. Прод-след: 15 + оборванных прогонов с доказанной работой (35 706 + 9 222 fetched) и БЕЗ ключа + вообще. Мерж делает точку монотонной для любого писателя, а не только для знающих. """ total_seen, new_count = _column_counts(counters) db.execute( @@ -583,7 +592,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None """ UPDATE scrape_runs SET heartbeat_at = clock_timestamp(), - counters = CAST(:counters AS jsonb), + counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) WHERE id = :run_id @@ -641,7 +650,7 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: UPDATE scrape_runs SET status = 'done', finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), - counters = CAST(:counters AS jsonb), + counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb), 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' @@ -681,7 +690,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) UPDATE scrape_runs SET status = 'failed', finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), - error = :error, counters = CAST(:counters AS jsonb), + error = :error, + counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb), 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' @@ -715,8 +725,9 @@ def mark_banned( Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'. Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим - багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его - теряет). Cooldown 2-4 часа. + багом. Чекпоинт done_buckets раньше сохранял только этот финализатор — теперь + counters мержатся во всех (см. update_heartbeat), и точка переживает любой из них. + Cooldown 2-4 часа. `ban_kind` разводит два исхода, которые раньше схлопывались в один статус: - BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha); @@ -742,7 +753,8 @@ def mark_banned( UPDATE scrape_runs SET status = 'banned', finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), - error = :error, counters = CAST(:counters AS jsonb), + error = :error, + counters = COALESCE(counters, CAST('{}' AS jsonb)) || 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) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 06d778a8..8b0b5009 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -497,6 +497,132 @@ def _claim_run(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) return run_id +# ── подхват чекпоинта оборванного прогона (#930, вторая половина) ──────────── +# #930 сделал чекпоинт (`counters.done_buckets`) и приёмную сторону +# (`run_*_full_load(resume_run_id=...)`), но единственным входом оставил админку. У +# avito full-load её нет вовсе, поэтому 299 из 433 «впустую перебранных» корзин за 90 +# суток не имели НИКАКОГО пути возобновления, даже ручного. Планировщик передавал +# resume_run_id=None литералом. +# +# Точка берётся только когда выполнены ВСЕ условия ниже; иначе прогон честно начинает с +# нуля, а ПРИЧИНА пишется в его counters (молчаливый отказ неотличим от отсутствия +# правки — см. _resume_decision). + +# 'zombie' НЕ в списке НАМЕРЕННО. reap_zombies снимает пометку 'running', но НЕ убивает +# процесс (прямо задокументировано в app/tasks/listing_source_snapshot.py) — а +# has_running_run гейтит claim именно по статусу. То есть после reap'а старый сборщик +# может продолжать писать в ту же строку: подхват читал бы ДВИЖУЩУЮСЯ точку и запускал +# второй сборщик на ту же площадку. 147 корзин в 10 zombie-прогонах за 90 суток +# остаются несобранными сознательно — это цена, а не недосмотр. +# 'failed' в списке: его чекпоинт больше не стирается финализатором (runs.py, мерж +# counters), а причина отказа («наш баг») ничего не говорит о полноте УЖЕ записанных +# корзин — они записаны тем же heartbeat'ом, что и у banned. +_RESUME_STATUSES = frozenset({"banned", "cancelled", "failed"}) + +# Длина цепочки возобновлений. Не круглое число: STALE_DIGEST_INTERVAL_FACTOR (=3) — +# уже существующий в этом файле порог «источник не собирал дольше 3× своего такта = +# сломан». Цепочка не имеет права отодвинуть полный обход дальше этой же черты, +# поэтому подряд идущих подхватов допускается на один меньше: прогоны 1 и 2 могут +# продолжать предшественника, третий обязан пойти с нуля. При такте avito 7 суток это +# гарантирует попытку полного обхода не реже, чем раз в 21 сутки — ровно в тот момент, +# когда сводка объявляет источник просроченным. +_MAX_RESUME_CHAIN = STALE_DIGEST_INTERVAL_FACTOR - 1 + +# Кандидат — ПОСЛЕДНИЙ прогон источника, а не последний подходящий: если после обрыва +# уже прошёл полный ('done') прогон, дерево обойдено и возобновлять нечего. Строки +# 'skipped' — бухгалтерия планировщика, а не прогоны, поэтому не в счёт. +_RESUME_CANDIDATE_SQL = text(""" + WITH cur AS ( + SELECT source, params FROM scrape_runs WHERE id = CAST(:rid AS bigint) + ) + SELECT r.id AS prev_id, + r.status AS prev_status, + r.counters AS prev_counters, + (r.params IS NOT DISTINCT FROM cur.params) AS same_params, + EXTRACT(EPOCH FROM (clock_timestamp() - r.heartbeat_at)) / 3600.0 AS age_h, + cur.params ->> 'interval_days' AS interval_days + FROM scrape_runs r, cur + WHERE r.source = cur.source + AND r.id <> CAST(:rid AS bigint) + AND r.status <> 'skipped' + ORDER BY r.started_at DESC + LIMIT 1 +""") + + +def _resume_decision(row: Any) -> tuple[int | None, dict[str, Any]]: + """Решение «подхватывать ли точку» + счётчики-объяснение. Чистая функция. + + Возвращает (resume_run_id | None, counters-заготовка нового прогона). Причина + отказа — машиночитаемый слаг в `resume_reason`, по нему «предыдущего прогона не + было» отличается от «параметры разъехались» ЗАПРОСОМ, а не грепом логов. + + Срок годности точки — такт источника ПЛЮС сутки (`interval_days` из его же params). + Ни одно из слагаемых не выбрано произвольно. Такт — объявленный самим расписанием + срок, в течение которого собранное считается свежим; точка старше него пережила + цикл, в котором источник обязан был обойти дерево целиком. Сутки — сетка запуска: + `compute_next_run_at` выбирает ДЕНЬ (сегодня+interval_days) и случайное время внутри + окна, поэтому два соседних запуска отстоят друг от друга на interval_days ± меньше + суток. Без этого слагаемого точку отвергал бы jitter расписания, а не устаревание: + прогон 3547 убит деплоем 09.08 16:53, расписание 139 подхватит его 16.08 13:37 — + 164.6 ч при такте 168 ч, запас 3.4 ч при ширине окна 2 ч. Пропущенный цикл в окно + всё равно не влезает: для avito это 13 суток против порога 8. + """ + if row is None: + return None, {"resume_from": None, "resume_reason": "no_prev_run", "resume_chain": 0} + + prev_counters = row.prev_counters if isinstance(row.prev_counters, dict) else {} + done_buckets = prev_counters.get("done_buckets") + done_n = len(done_buckets) if isinstance(done_buckets, list) else 0 + chain_raw = prev_counters.get("resume_chain") + prev_chain = chain_raw if isinstance(chain_raw, int) else 0 + verdict: dict[str, Any] = { + "resume_from": None, + "resume_candidate": int(row.prev_id), + "resume_buckets": done_n, + "resume_chain": 0, + } + + if row.prev_status not in _RESUME_STATUSES: + verdict["resume_reason"] = f"status_{row.prev_status}" + elif not row.same_params: + verdict["resume_reason"] = "params_changed" + elif done_n == 0: + verdict["resume_reason"] = "no_checkpoint" + elif row.age_h is None or float(row.age_h) > 24.0 * ( + _schedule_interval_days(row.interval_days) + 1 + ): + verdict["resume_reason"] = "checkpoint_stale" + elif prev_chain >= _MAX_RESUME_CHAIN: + verdict["resume_reason"] = "chain_limit" + else: + verdict["resume_from"] = int(row.prev_id) + verdict["resume_reason"] = "ok" + verdict["resume_chain"] = prev_chain + 1 + return int(row.prev_id), verdict + return None, verdict + + +def _pick_resume(db: Session, run_id: int) -> int | None: + """Чекпоинт какого прогона наследует `run_id` (или None) + запись вердикта. + + Тождество задания сверяется РОВНО по тем полям, которыми задание задаётся: source + (кандидат ищется в пределах одного source) и params целиком, побайтово. Кандидат + сравнивается с ТЕКУЩИМ прогоном, а не с расписанием, потому что именно params + прогона поехали в pipeline. На проде за 90 суток 89 корзин из 433 (21%) лежат в + прогонах, чьи params отличаются от следующего — по ним пропуск был бы неверным: + `incremental_days` меняет СМЫСЛ ключа (дочитано до watermark ≠ бакет перебран), а + `price_cap_per_bucket` меняет само дерево бисекции, то есть какие ключи существуют. + """ + row = db.execute(_RESUME_CANDIDATE_SQL, {"rid": run_id}).fetchone() + resume_run_id, verdict = _resume_decision(row) + # Вердикт кладём в counters НОВОГО прогона: `update_heartbeat` мержит jsonb, поэтому + # последующие heartbeat'ы пайплайна его не затрут и он доживёт до финализатора. + _kit_runs.update_heartbeat(db, run_id, verdict) + logger.info("scheduler: resume run_id=%d — %s", run_id, verdict) + return resume_run_id + + def _defer_next_run_at(db: Session, schedule_row: dict[str, Any]) -> None: """Сдвинуть next_run_at на следующее окно БЕЗ создания run (#1522). @@ -705,7 +831,7 @@ async def _job_avito_full_load( concurrency=int(params.get("concurrency", 5)), request_delay_sec=float(params.get("request_delay_sec", 7.0)), secondary_only=bool(params.get("secondary_only", True)), - resume_run_id=None, + resume_run_id=_pick_resume(db, run_id), incremental_days=incremental_days, ) @@ -725,7 +851,7 @@ async def _job_avito_full_load_exhaustive( concurrency=int(params.get("concurrency", 5)), request_delay_sec=float(params.get("request_delay_sec", 7.0)), secondary_only=bool(params.get("secondary_only", True)), - resume_run_id=None, + resume_run_id=_pick_resume(db, run_id), incremental_days=None, ) @@ -796,7 +922,7 @@ async def _job_cian_full_load( request_delay_sec=float(params.get("request_delay_sec", 4.0)), enrich_detail=bool(params.get("enrich_detail", False)), detail_top_n=int(params.get("detail_top_n", 0)), - resume_run_id=None, + resume_run_id=_pick_resume(db, run_id), ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index 3c3d717a..95e2618a 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -1095,7 +1095,9 @@ class AvitoScraper(BaseScraper): leaf-бакета. Может быть async или sync. Исключение прерывает прогон. on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket). skip_buckets: множество ключей «room_label:lo:hi» уже завершённых бакетов — - пагинация и on_bucket для них пропускаются. Probe-запросы выполняются. + пагинация и on_bucket для них пропускаются. В exhaustive-режиме probe-запросы + всё равно выполняются (skip проверяется уже в листе, после probe), в + инкрементальном probe'а нет — там пропускается весь бакет целиком. since: если None (default) — EXHAUSTIVE bisection-обход (поведение без изменений). Если задана date — INCREMENTAL: на каждый (комнатность × seed-брекет) последовательная пагинация newest-first с ранней остановкой, @@ -1141,6 +1143,7 @@ class AvitoScraper(BaseScraper): max_pages_per_bucket=max_pages_per_bucket, secondary_only=secondary_only, on_bucket=on_bucket, + skip_buckets=skip_buckets, ) else: await self._walk_price_range( @@ -1390,14 +1393,17 @@ class AvitoScraper(BaseScraper): # иначе фетчим её как обычную страницу (открытый брекет без probe-html). sem = asyncio.Semaphore(concurrency) first_url = self._build_rooms_url(room_slug, 1, _lo_param, _hi_param) + dropped_pages = 0 # страницы, не отдавшие карточки по отказу (не по пустоте) async def _one_page(p: int) -> list[ScrapedLot]: + nonlocal dropped_pages if p == 1 and html is not None: return self._parse_html(html, source_url_base=first_url) async with sem: page_html = await self._fetch_rooms_page_html(room_slug, p, _lo_param, _hi_param) await asyncio.sleep(self.request_delay_sec) if page_html is None: + dropped_pages += 1 logger.warning( "avito: page_html=None %s [%d, %s] page=%d — skipping page", room_label, @@ -1420,6 +1426,7 @@ class AvitoScraper(BaseScraper): # Блокировки пробрасываем наверх (mark_banned в pipeline-обёртке). if isinstance(res, AvitoBlockedError | AvitoRateLimitedError): raise res + dropped_pages += 1 logger.warning( "avito: page exception %s [%d, %s] page=%d — %r", room_label, @@ -1464,8 +1471,25 @@ class AvitoScraper(BaseScraper): if key: seen[key] = lot + # ── Полнота бакета: ключ у целиком и частично собранного ОДИНАКОВ ───── + # `bucket_key` — это "room:lo:hi" и больше ничего, поэтому «сделано» едет + # отдельным аргументом. Три пути частичности сходятся здесь: + # 1. страница молча выпала (page_html=None выше); + # 2. исключение страницы проглочено gather'ом (return_exceptions=True); + # 3. признанный tail-loss — probe провалился (expected_total=None, открытый + # брекет) или страниц нужно больше, чем max_pages. + # Ни один из них не оставлял следа в чекпоинте: следующий прогон видел ключ и + # пропускал бакет. Пока признак не доехал до done_buckets, включать подхват + # нельзя — видимая потеря (перескрап) стала бы невидимой (пропуск страниц). + pages_needed = ( + math.ceil(expected_total / _AVITO_OFFERS_PER_PAGE) + if expected_total is not None + else None + ) + complete = dropped_pages == 0 and pages_needed is not None and pages_needed <= max_pages logger.info( - "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d", + "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d " + "complete=%s dropped_pages=%d", room_label, lo, _hi_repr, @@ -1473,11 +1497,13 @@ class AvitoScraper(BaseScraper): collected_this_bucket, dropped_nb, len(seen), + complete, + dropped_pages, ) # ── on_bucket callback: инкрементальный save ────────────────────────── if on_bucket is not None and bucket_lots: - res_cb = on_bucket(bucket_key, bucket_lots) + res_cb = on_bucket(bucket_key, bucket_lots, complete) if inspect.isawaitable(res_cb): await res_cb @@ -1493,6 +1519,7 @@ class AvitoScraper(BaseScraper): max_pages_per_bucket: int, secondary_only: bool, on_bucket: Callable[..., Any] | None, + skip_buckets: set[str] | None = None, ) -> None: """INCREMENTAL пагинация одного (комнатность × seed-брекет) с ранней остановкой. @@ -1514,16 +1541,37 @@ class AvitoScraper(BaseScraper): bucket_key, secondary_only-фильтр и дедуп в seen — идентичны _paginate_leaf_bucket. on_bucket вызывается один раз для собранного бакета (async/sync-aware). AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх. + + skip_buckets: ключи, дочитанные ПРЕДЫДУЩИМ прогоном с ТЕМИ ЖЕ params (тождество + задания проверяет планировщик, `_pick_resume`). В инкрементальном режиме + «сделано» значит «дочитал до watermark `since`», а не «перебрал бакет целиком», + поэтому смешивать такой ключ с exhaustive-ключом нельзя — они дословно совпадают + (6 из 11 seed-ключей), но означают разное. Пропуск здесь безопасен по покрытию + ровно потому, что окно ретроспективы не уже такта (#2674, гарантируется + _job_avito_full_load): бакет, дочитанный до watermark N суток назад, следующий + плановый прогон перечитает со своим since = сегодня−N и увидит всё, что успело + появиться. Раньше аргумент сюда не передавался вовсе — resume в боевом + (инкрементальном) режиме avito_full_load был чистым no-op: 134 из 242 корзин. + + complete=False (см. `_paginate_leaf_bucket`) отдаётся, когда бакет НЕ дочитан до + watermark: страница выпала, или страниц не хватило (max_pages), или остановка + произошла по grace-эвристике «2 подряд недатированные страницы» — там watermark + не доказан, а не достигнут. """ _lo_param = lo if lo > 0 else None _hi_param = hi # None → _build_rooms_url не ставит pmax _hi_repr = "open" if hi is None else str(hi) bucket_key = f"{room_label}:{lo}:{_hi_repr}" + if skip_buckets and bucket_key in skip_buckets: + logger.info("avito: skip bucket %s — already read to watermark (resume)", bucket_key) + return + bucket_lots: list[ScrapedLot] = [] pages_fetched = 0 not_fresh_streak = 0 # подряд идущие не-свежие (all None-или-старые) страницы stop_reason = "max-pages" # перетирается ниже на реальную причину + complete = False # дочитан ли бакет до watermark; True только на честных стопах for p in range(1, max_pages_per_bucket + 1): # Последовательный фетч (НЕ asyncio.gather): early-stop требует читать @@ -1540,6 +1588,7 @@ class AvitoScraper(BaseScraper): page_lots = self._parse_html(page_html, source_url_base=page_url) if not page_lots: stop_reason = "end-of-pages (empty parse)" + complete = True # выдача кончилась — читать в этом брекете больше нечего break bucket_lots.extend(page_lots) @@ -1556,6 +1605,7 @@ class AvitoScraper(BaseScraper): # Есть даты, но все < since → newest-first гарантирует, что дальше # только старее → стоп немедленно. stop_reason = "early-stop (page all older than since)" + complete = True # watermark достигнут — ровно то, что значит «сделано» break # Все карточки undated (None) → не стопим сразу (могут быть свежие без # даты), но копим streak; 2 подряд недатированные страницы → grace-стоп. @@ -1581,7 +1631,7 @@ class AvitoScraper(BaseScraper): logger.info( "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d " - "unique_total=%d incremental since=%s stop=%s", + "unique_total=%d incremental since=%s stop=%s complete=%s", room_label, lo, _hi_repr, @@ -1591,11 +1641,12 @@ class AvitoScraper(BaseScraper): len(seen), since.isoformat(), stop_reason, + complete, ) # ── on_bucket callback: инкрементальный save ────────────────────────── if on_bucket is not None and bucket_lots: - res_cb = on_bucket(bucket_key, bucket_lots) + res_cb = on_bucket(bucket_key, bucket_lots, complete) if inspect.isawaitable(res_cb): await res_cb diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py index e046ef67..67b5c302 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py @@ -621,8 +621,10 @@ class CianScraper(BaseScraper): # Страница 1 уже есть (html из probe выше); остальные — параллельно. sem = asyncio.Semaphore(concurrency) + dropped_pages = 0 if html else 1 # пустой probe-html = страница 1 не собрана async def _one_page(p: int) -> list[ScrapedLot]: + nonlocal dropped_pages if p == 1: # Используем уже полученный HTML от probe return self._parse_serp_html(html) if html else [] @@ -630,6 +632,7 @@ class CianScraper(BaseScraper): page_html = await self._fetch_page_html(rooms, p, _lo_param, hi) await asyncio.sleep(self.request_delay_sec) if page_html is None: + dropped_pages += 1 logger.warning( "cian: page_html=None %s [%d, %s] page=%d — skipping page", room_label, @@ -648,6 +651,7 @@ class CianScraper(BaseScraper): bucket_lots: list[ScrapedLot] = [] for p_idx, res in enumerate(page_results, start=1): if isinstance(res, BaseException): + dropped_pages += 1 logger.warning( "cian: page exception %s [%d, %s] page=%d — %r", room_label, @@ -674,8 +678,16 @@ class CianScraper(BaseScraper): if key: seen[key] = lot + # ── Полнота бакета: ключ у целиком и частично собранного ОДИНАКОВ ───── + # См. avito/serp.py — те же два пути частичности (выпавшая страница, + # проглоченное gather'ом исключение) плюс признанный hard-cap выше. У cian это + # тяжелее: в SERP-слое НЕТ класса блок-исключения вообще (ban-детект #2625 + # агрегатный, на выходе из скрапера), поэтому капча посреди бакета приходит сюда + # как page_html=None и раньше давала «сделанный» бакет из уцелевших страниц. + complete = dropped_pages == 0 and pages_needed <= max_pages logger.info( - "cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d", + "cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d " + "complete=%s dropped_pages=%d", room_label, lo, _hi_repr, @@ -683,11 +695,13 @@ class CianScraper(BaseScraper): collected_this_bucket, dropped_nb, len(seen), + complete, + dropped_pages, ) # ── on_bucket callback: инкрементальный save ────────────────────────── if on_bucket is not None and bucket_lots: - res_cb = on_bucket(bucket_key, bucket_lots) + res_cb = on_bucket(bucket_key, bucket_lots, complete) if inspect.isawaitable(res_cb): await res_cb From e59a102b16491c3acf51ecc07de6c411bb952f32 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 18:51:10 +0000 Subject: [PATCH 04/60] =?UTF-8?q?fix(tradein/dkp):=20=C2=AB=D0=A4=D0=90?= =?UTF-8?q?=D0=9A=D0=A2=D0=98=D0=A7=D0=95=D0=A1=D0=9A=D0=98=D0=95=20=D0=A1?= =?UTF-8?q?=D0=94=D0=95=D0=9B=D0=9A=D0=98=C2=BB=20=D0=BD=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=8E=D1=82=20=D1=81=D0=B2=D0=BE=D0=B9=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D1=80=D0=B0=D1=81=D1=82,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20(#2847)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/schemas/trade_in.py | 15 +- tradein-mvp/backend/app/services/estimator.py | 14 ++ .../app/services/exporters/trade_in_pdf.py | 40 +++- .../tests/test_dkp_corridor_as_of_2846.py | 208 ++++++++++++++++++ .../src/app/ui-preview/estimate/fixture.ts | 4 + .../src/components/trade-in/HeroSummary.tsx | 11 +- .../trade-in/v2/__tests__/dealsAsOf.test.ts | 175 +++++++++++++++ .../src/components/trade-in/v2/mappers.ts | 46 +++- tradein-mvp/frontend/src/lib/rosreestr.ts | 35 +++ tradein-mvp/frontend/src/types/trade-in.ts | 11 +- 10 files changed, 546 insertions(+), 13 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 923a7607..2c29d640 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -153,7 +153,20 @@ class DkpCorridor(BaseModel): low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор) median_ppm2: int # медиана ₽/м² high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор) - period_months: int # окно поиска сделок + period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date) + # #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/ + # median/high — включая city-wide widen, если сработал), НЕ по всей таблице. + # period_months отвечает на «где искали», а не «насколько свежи сделки»: прод + # 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок + # даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы + # враньём в пользу свежести именно для них. + # Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день + # квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974 + # сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно + # {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи + # на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад». + # None = сделки без даты (в проде не встречается) — потребитель молчит. + latest_deal_date: date | None = None class PriceTrendPoint(BaseModel): diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 7f386bb9..9acab16d 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1772,6 +1772,10 @@ def _fetch_dkp_corridor( series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION) adjusted: list[float] = [] factors_applied: list[float] = [] + # #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted — + # `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы + # заявленную свежесть коридора, не участвуя в его границах. + latest_deal: date | None = None for r in rows: ppm2 = r["price_per_m2"] if not ppm2: @@ -1783,6 +1787,8 @@ def _fetch_dkp_corridor( factor = _sber_time_factor(series, deal_month) adjusted.append(float(ppm2) * factor) factors_applied.append(factor) + if dd is not None and (latest_deal is None or dd > latest_deal): + latest_deal = dd ppm2_values = sorted(adjusted) # #oblast-D widen: a single street in a small non-EKB town can easily have @@ -1835,6 +1841,7 @@ def _fetch_dkp_corridor( city_rows = [] city_adjusted: list[float] = [] + city_latest: date | None = None for r in city_rows: ppm2 = r["price_per_m2"] if not ppm2: @@ -1845,6 +1852,8 @@ def _fetch_dkp_corridor( deal_month = date(dd.year, dd.month, 1) factor = _sber_time_factor(series, deal_month) city_adjusted.append(float(ppm2) * factor) + if dd is not None and (city_latest is None or dd > city_latest): + city_latest = dd if len(city_adjusted) > len(ppm2_values): logger.info( @@ -1855,6 +1864,10 @@ def _fetch_dkp_corridor( city, ) ppm2_values = sorted(city_adjusted) + # #2846: коридор теперь описывает city-выборку — и возраст обязан + # переехать вместе с числами, иначе подпись осталась бы от street- + # выборки, которую на витрине уже никто не видит. + latest_deal = city_latest if not ppm2_values: return None @@ -1877,6 +1890,7 @@ def _fetch_dkp_corridor( "median_ppm2": int(_percentile(ppm2_values, 0.5)), "high_ppm2": int(_percentile(ppm2_values, 0.90)), "period_months": period_months, + "latest_deal_date": latest_deal, } 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 a89eadf6..8e02aae4 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -1516,11 +1516,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str: # ── Page 3: Deals ──────────────────────────────────────────────────────────── +_ROMAN_QUARTER = ("I", "II", "III", "IV") + + +def deals_as_of_label(estimate: AggregatedEstimate) -> str | None: + """#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки. + + Раньше страница печатала «Период сделок: 08.2025 – 08.2026» — окно ПОИСКА, + посчитанное как `today - period_months*30 … today`. Правым концом оно обещало + сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде + (замер 2026-08-12) — I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно + по тем сделкам, из которых страница строит диапазон и таблицу примеров. + + Гранулярность — квартал: rosreestr пишет deal_date = первый день квартала + (#1995, ровно то, что помечает AnalogLot.date_precision == "quarter"). + Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания — + сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока + такого нет) подписывается месяцем. + + None — сделок нет либо ни у одной нет даты: подписывать нечего. + """ + dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date] + if not dated: + return None + newest, precision = max(dated, key=lambda p: p[0]) + if precision == "day": + return f"по {newest.strftime('%m.%Y')}" + return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}" + def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] n_deals = len(estimate.actual_deals) - today = dt.date.today() - period_start = today - dt.timedelta(days=estimate.period_months * 30) + # #2846: «Период сделок» показывал окно поиска правым концом = сегодня. + # Реальная граница — as-of по показанным сделкам; окна поиска на странице + # больше нет (оно ничего не говорило о данных). None → строку не печатаем. + deals_as_of = deals_as_of_label(estimate) # Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа # (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%», @@ -1597,9 +1627,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) - - + {f''' + ''' + if deals_as_of else ""}
Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Период сделок - {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}
Сделки{_mono(deals_as_of)}
Источники данных
diff --git a/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py new file mode 100644 index 00000000..85ed4502 --- /dev/null +++ b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py @@ -0,0 +1,208 @@ +"""#2846 — возраст выборки ДКП-коридора обязан доезжать до ответа. + +Плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» несла count/low/median/high и +period_months — ОКНО ПОИСКА. Возраста самих сделок в схеме не было вовсе, и +слово «ФАКТИЧЕСКИЕ» читалось как «недавние». + +Замер прода 2026-08-12 (docker exec tradein-postgres psql -U tradein -d tradein): + + deals source='rosreestr': 96 974 строки, max(deal_date) = 2026-01-01 + (223 дня назад), 9 различных deal_date, day-of-month = 1 у 100% строк, + месяцы ровно {01, 04, 07, 10} → deal_date это метка КВАРТАЛЬНОЙ пачки. + + Реплей выборок 881 реальной оценки за 90 суток (улица+город+rooms+area±15% + +окно 12 мес + ppm²-банды, включая city-wide widen): + свежайшая I кв. 2026 — 654 (74.2%) + коридора нет вовсе — 165 (18.7%) + свежайшая IV кв. 2025 — 62 ( 7.0%) + → у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего + максимума таблицы, т.е. max по всей таблице был бы враньём в их пользу. + +Красный прогон на origin/main: тесты падают на отсутствии latest_deal_date в +dict/схеме (AttributeError / KeyError), а не на ожидании, списанном с настройки. +""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.schemas.trade_in import AnalogLot, DkpCorridor +from app.services.estimator import _fetch_dkp_corridor + +_ADDRESS = "Екатеринбург, ул. Гоголя, 18" + + +def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]: + return {"price_per_m2": ppm2, "deal_date": deal_date} + + +def _db(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None) -> Any: + """Session-двойник: 1-й execute — СберИндекс, 2-й — улица, 3-й — city-widen. + + Порядок жёстко следует _fetch_dkp_corridor: сначала street-выборка, затем + _load_sber_index_series, затем (опционально) city-wide widen. + """ + db = MagicMock() + calls: list[Any] = [] + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + calls.append(query) + sql = str(query) + if "sber" in sql.lower(): + result.mappings.return_value.all.return_value = [] + elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql: + result.mappings.return_value.all.return_value = street_rows + elif "deal_city_price_bands" in sql: + result.mappings.return_value.all.return_value = city_rows or [] + else: + result.mappings.return_value.all.return_value = [] + return result + + db.execute.side_effect = _execute + return db + + +def _fetch(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None): # type: ignore[no-untyped-def] + return _fetch_dkp_corridor( + _db(street_rows, city_rows), + address=_ADDRESS, + rooms=2, + area=60.0, + city="екатеринбург" if city_rows is None else "нижний тагил", + ) + + +# ── КРАСНОЕ ЯДРО: возраст доезжает до схемы ───────────────────────────────── + + +def test_latest_deal_date_reaches_the_response_schema() -> None: + """Главный красный: DkpCorridor обязан НЕСТИ дату свежайшей сделки. + + На origin/main падает AttributeError — поля в схеме нет, а Pydantic v2 + молча игнорирует лишний kwarg, так что «оно как-нибудь пролезет» — нет. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(210_000, date(2026, 1, 1))]) + assert raw is not None + corridor = DkpCorridor(**raw) + assert corridor.latest_deal_date == date(2026, 1, 1) + + +def test_age_is_taken_from_the_selected_sample_not_the_table_max() -> None: + """Выборка кончается IV кв. 2025 → подпись обязана назвать IV кв. 2025. + + Это те 7.0% реальных оценок из шапки модуля. Общий максимум таблицы + (2026-01-01) для них — чужая, более свежая дата. + """ + raw = _fetch([_deal(190_000, date(2025, 7, 1)), _deal(195_000, date(2025, 10, 1))]) + assert raw is not None + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +def test_deal_without_price_does_not_donate_its_freshness() -> None: + """Строка без ppm² не входит в границы коридора — не входит и в его возраст. + + Иначе самая свежая, но выброшенная сделка омолаживала бы подпись, не + участвуя ни в одном из чисел под ней. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(None, date(2026, 1, 1))]) + assert raw is not None + assert raw["count"] == 1 + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +def test_city_wide_widen_moves_the_age_together_with_the_numbers() -> None: + """#oblast-D widen: числа переехали на city-выборку — дата обязана тоже. + + Street-выборка тут СВЕЖЕЕ (I кв. 2026) и её дата осталась бы приятнее для + глаза; но на экране после widen'а стоят city-числа, которые кончаются + III кв. 2025. Подпись описывает то, что показано. + """ + street = [_deal(200_000, date(2026, 1, 1))] + city = [_deal(150_000, date(2025, 4, 1)), _deal(160_000, date(2025, 7, 1))] + raw = _fetch(street, city) + assert raw is not None + assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)" + assert raw["latest_deal_date"] == date(2025, 7, 1) + + +def test_no_deals_means_no_corridor_and_nothing_to_date() -> None: + """count=0 → коридора нет вовсе; новое поле не создаёт повода что-то рисовать.""" + assert _fetch([]) is None + + +def test_period_months_is_not_a_freshness_claim() -> None: + """Регресс-якорь: окно поиска и возраст данных — РАЗНЫЕ числа. + + Ровно та подмена, из-за которой v2 отказался печатать «за N месяцев» + (mappers.ts) — а v1 печатал. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1))]) + assert raw is not None + assert raw["period_months"] == 12 + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +# ── PDF: то же обещание, та же подпись ────────────────────────────────────── + + +def _estimate_with_deals(dates: list[date]) -> Any: + est = MagicMock() + est.actual_deals = [ + AnalogLot( + address="Екатеринбург, ул. Гоголя", + area_m2=60.0, + rooms=2, + floor=None, + total_floors=None, + price_rub=12_000_000, + price_per_m2=200_000, + listing_date=d, + days_on_market=None, + date_precision="quarter", + source="rosreestr", + ) + for d in dates + ] + return est + + +def test_pdf_deals_page_dates_the_deals_it_shows() -> None: + """PDF §03 «ФАКТИЧЕСКИЕ СДЕЛКИ» — то же обещание, что и плитка. + + До правки страница печатала «Период сделок: 08.2025 – 08.2026», где правый + конец = сегодня: окно поиска, выданное за период данных. + """ + from app.services.exporters.trade_in_pdf import deals_as_of_label + + assert deals_as_of_label(_estimate_with_deals([date(2025, 10, 1), date(2026, 1, 1)])) == ( + "по I кв. 2026" + ) + assert deals_as_of_label(_estimate_with_deals([])) is None + + +def test_quarter_label_matches_every_deal_date_present_on_prod() -> None: + """Все 9 живых deal_date проекции 2026-08-12 → корректная римская метка. + + Список не выдуман: это ровно `SELECT DISTINCT deal_date FROM deals`. + """ + from app.services.exporters.trade_in_pdf import deals_as_of_label + + expected = { + date(2026, 1, 1): "по I кв. 2026", + date(2025, 10, 1): "по IV кв. 2025", + date(2025, 7, 1): "по III кв. 2025", + date(2025, 4, 1): "по II кв. 2025", + date(2025, 1, 1): "по I кв. 2025", + date(2024, 10, 1): "по IV кв. 2024", + date(2024, 7, 1): "по III кв. 2024", + date(2024, 4, 1): "по II кв. 2024", + date(2024, 1, 1): "по I кв. 2024", + } + for d, label in expected.items(): + assert deals_as_of_label(_estimate_with_deals([d])) == label 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 e441325a..ec10ab75 100644 --- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -175,6 +175,10 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = { median_ppm2: 162_000, high_ppm2: 178_000, period_months: 24, + // #2846: свежайшая сделка ВЫБОРКИ. Совпадает с прод-максимумом на 2026-08-12 + // (deals.max(deal_date) = 2026-01-01) — оффлайн-превью должно показывать + // ту же подпись «по I кв. 2026», что и живая страница. + latest_deal_date: "2026-01-01", }, price_trend: [ { month: "2025-11", ppm2: 168_000 }, diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 1b89abe9..652bd91e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -11,6 +11,7 @@ import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { sourceDot, sourceLabel } from "@/lib/source-registry"; +import { dealsAsOfLabel } from "@/lib/rosreestr"; import { HeroTransparency } from "./HeroTransparency"; interface Props { @@ -175,6 +176,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0; const showDkp = !!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0; + // #2846: возраст ОТОБРАННЫХ сделок. Раньше здесь стояло «за {period_months} + // мес» — окно ПОИСКА, выданное за период данных: «12 мес» читалось как «сделки + // по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026. + // null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает. + const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter"); // Progressive enrichment state const needsHouseType = estimate.house_type === null; @@ -547,6 +553,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд. + {dkpAsOf ? ` · ${dkpAsOf}` : ""} )} @@ -601,8 +608,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
- {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП за{" "} - {dkp.period_months} мес + {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП + {dkpAsOf ? ` · ${dkpAsOf}` : ""}
)} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts new file mode 100644 index 00000000..9b8f6480 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts @@ -0,0 +1,175 @@ +// #2846 — плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» обязана назвать возраст. +// +// До правки автор mappers.ts честно писал: «No "ЗА N МЕСЯЦЕВ" — we have no honest +// period to show», потому что единственным кандидатом был period_months — ОКНО +// ПОИСКА. Он был прав про поле и неправ про молчание: слово «ФАКТИЧЕСКИЕ» +// читается как «недавние», а свежайшая пачка Росреестра на проде (замер +// 2026-08-12) — I кв. 2026, 223 дня от её начала. +// +// Плитку кормят ТРИ РАЗНЫЕ выборки (street-deals → dkp_corridor → actual_deals), +// поэтому здесь проверяется каждая: подпись обязана описывать тот набор, чьи +// числа сейчас на экране, а не тот, который удобнее. + +import { describe, expect, it } from "vitest"; + +import { mapResultPanel } from "../mappers"; +import { dealsAsOfLabel } from "@/lib/rosreestr"; +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { + AggregatedEstimate, + AnalogLot, + StreetDealsResponse, +} from "@/types/trade-in"; + +/** ppm-строка плитки ДКП — там же, где «₽/м² · N сделок». */ +function dkpPpm( + e: AggregatedEstimate, + sd: StreetDealsResponse | null = null, +): string { + const card = mapResultPanel(e, sd).cards.find((c) => + c.title.join(" ").includes("ДКП"), + ); + if (!card) throw new Error("плитка ДКП пропала из панели результата"); + return card.ppm; +} + +function deal(over: Partial): AnalogLot { + return { + address: "ул. Гоголя, 18", + area_m2: 60, + rooms: 2, + floor: 4, + total_floors: 9, + price_rub: 12_000_000, + price_per_m2: 200_000, + listing_date: "2026-01-01", + days_on_market: null, + photo_url: null, + source: "rosreestr", + source_url: null, + distance_m: null, + tier: "T1_per_street", + date_precision: "quarter", + lat: null, + lon: null, + ...over, + }; +} + +function streetDeals(deals: AnalogLot[]): StreetDealsResponse { + return { + street: "Гоголя", + period_from: "2025-08-12", + period_to: "2026-08-12", + count: deals.length, + median_price_rub: 12_000_000, + median_price_per_m2: 200_000, + range_low_rub: 10_000_000, + range_high_rub: 14_000_000, + deals, + }; +} + +// Ветка 2: только коридор, без street-deals и без actual_deals. +const CORRIDOR_ONLY: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + actual_deals: [], + area_m2: 60, + dkp_corridor: { + count: 9, + low_ppm2: 150_000, + median_ppm2: 162_000, + high_ppm2: 178_000, + period_months: 24, + latest_deal_date: "2026-01-01", + }, +}; + +describe("#2846 возраст выборки ДКП на плитке", () => { + it("коридор: возраст выборки доезжает до ppm-строки", () => { + // Красный на origin/main: latest_deal_date некуда положить и некому прочесть. + expect(dkpPpm(CORRIDOR_ONLY)).toContain("по I кв. 2026"); + }); + + it("подпись называет ВЕРХНЮЮ границу свежести, а не окно поиска", () => { + // period_months = 24 у той же карточки. «за 24 месяца» при сделках января — + // ровно та подмена, из-за которой подписи не было вовсе. + const ppm = dkpPpm(CORRIDOR_ONLY); + expect(ppm).not.toContain("24"); + expect(ppm).not.toContain("мес"); + }); + + it("street-deals: подпись берётся у выборки, которая реально на экране", () => { + // Эта ветка приоритетнее коридора в resolveDealTier. Коридор тут заявляет + // I кв. 2026 — если бы подпись читалась из него, она омолодила бы чужие числа. + const ppm = dkpPpm( + CORRIDOR_ONLY, + streetDeals([ + deal({ listing_date: "2025-10-01" }), + deal({ listing_date: "2025-07-01" }), + ]), + ); + expect(ppm).toContain("по IV кв. 2025"); + expect(ppm).not.toContain("2026"); + }); + + it("actual_deals: третья ветка тоже датирована", () => { + const ppm = dkpPpm({ + ...FIXTURE_ESTIMATE, + dkp_corridor: null, + actual_deals: [ + deal({ listing_date: "2025-04-01" }), + deal({ listing_date: "2025-07-01" }), + ], + }); + expect(ppm).toContain("по III кв. 2025"); + }); + + it("сделок нет вовсе — подписи нет, а не «по …» под прочерком", () => { + const ppm = dkpPpm({ + ...FIXTURE_ESTIMATE, + dkp_corridor: null, + actual_deals: [], + }); + expect(ppm).toBe("—"); + }); + + it("оценка старше деплоя бэкенда (поля нет) — молчим, как раньше", () => { + const legacy = { ...CORRIDOR_ONLY.dkp_corridor! }; + delete (legacy as { latest_deal_date?: string | null }).latest_deal_date; + const ppm = dkpPpm({ ...CORRIDOR_ONLY, dkp_corridor: legacy }); + expect(ppm).toContain("сделок"); + expect(ppm).not.toContain("кв."); + }); +}); + +describe("#2846 форма подписи", () => { + it("все 9 живых deal_date прода → римский квартал", () => { + // Не выдумано: ровно `SELECT DISTINCT deal_date FROM deals` на 2026-08-12. + expect(dealsAsOfLabel("2026-01-01")).toBe("по I кв. 2026"); + expect(dealsAsOfLabel("2025-10-01")).toBe("по IV кв. 2025"); + expect(dealsAsOfLabel("2025-07-01")).toBe("по III кв. 2025"); + expect(dealsAsOfLabel("2025-04-01")).toBe("по II кв. 2025"); + expect(dealsAsOfLabel("2025-01-01")).toBe("по I кв. 2025"); + expect(dealsAsOfLabel("2024-10-01")).toBe("по IV кв. 2024"); + expect(dealsAsOfLabel("2024-07-01")).toBe("по III кв. 2024"); + expect(dealsAsOfLabel("2024-04-01")).toBe("по II кв. 2024"); + expect(dealsAsOfLabel("2024-01-01")).toBe("по I кв. 2024"); + }); + + it("date-only ISO не уезжает на предыдущий квартал в западных зонах", () => { + // "2026-01-01" — UTC-полночь; локальные геттеры дали бы 31.12.2025 → IV кв. + expect(dealsAsOfLabel("2026-01-01")).toBe("по I кв. 2026"); + expect(dealsAsOfLabel("2026-04-01")).toBe("по II кв. 2026"); + }); + + it("источник с настоящей датой подписывается месяцем, а не кварталом", () => { + expect(dealsAsOfLabel("2026-05-12", "day")).toBe("по 05.2026"); + }); + + it("датировать нечего — null, а не заглушка", () => { + expect(dealsAsOfLabel(null)).toBeNull(); + expect(dealsAsOfLabel(undefined)).toBeNull(); + expect(dealsAsOfLabel("не дата")).toBeNull(); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 2af10439..777918d1 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -76,6 +76,7 @@ import type { } from "./types"; import { tokens } from "./tokens"; import { sourceLabel as registrySourceLabel } from "@/lib/source-registry"; +import { dealsAsOfLabel } from "@/lib/rosreestr"; // Total number of source slots — meta "N / M". Single source of truth is // SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES). @@ -599,12 +600,27 @@ interface DealTier { medianPpm: number; count: number; bars: number[]; + /** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */ + asOf: string | null; +} + +/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */ +function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] { + let best: AnalogLot | null = null; + for (const l of lots) { + if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l; + } + return [best?.listing_date ?? null, best?.date_precision ?? null]; } /** * Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with * real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's * own actual_deals. null when no deal data at all. + * + * #2846: каждая ветка тянет СВОЙ as-of — три ветки это три РАЗНЫЕ выборки + * (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до + * города), и подпись обязана описывать ту, чьи числа сейчас на экране. */ function resolveDealTier( e: AggregatedEstimate, @@ -621,6 +637,9 @@ function resolveDealTier( // same way the ads card does — otherwise a single mis-scraped lot bins // over [min,max] and crushes the real deals into the left buckets. bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean), + // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам, + // так что максимум по ним = максимум по всей выборке, не по показанным. + asOf: dealsAsOfLabel(...newestLot(sd.deals)), }; } const area = e.area_m2; @@ -636,6 +655,9 @@ function resolveDealTier( bars: bins8( guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean, ), + // Не из actual_deals: гистограмма заимствует их только как форму, а + // числа карточки (медиана/границы) — из коридора, у которого своя выборка. + asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), }; } if (e.actual_deals.length > 0) { @@ -652,6 +674,7 @@ function resolveDealTier( count: e.actual_deals.length, // Fix #1/#8: same high-outlier guard as the ads card (see above). bars: bins8(guardPriceOutliers(ppms).clean), + asOf: dealsAsOfLabel(...newestLot(e.actual_deals)), }; } return null; @@ -1135,11 +1158,26 @@ export function mapResultPanel( range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных", + // #2846: хвост «· по I кв. 2026» — возраст ОТОБРАННЫХ сделок. Слово + // «ФАКТИЧЕСКИЕ» в заголовке читается как «недавние», а свежайшая пачка + // Росреестра на проде — I кв. 2026; молчание тут обещало свежесть, + // которой нет. Отдельной строки не заводим: ppm-строка по контракту + // ResultCard уже носит хвост «· N сделок», третий факт про тот же набор + // встаёт в тот же ряд и читается одним взглядом. + // dealTier == null (сделок нет) → «—», подписи нет: без чисел датировать + // нечего, а «по I кв. 2026» под прочерком выглядело бы как скрытые данные. ppm: dealTier - ? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu( - dealTier.count, - ["сделка", "сделки", "сделок"], - )}` + ? [ + fmtPpm(dealTier.medianPpm), + `${dealTier.count} ${pluralRu(dealTier.count, [ + "сделка", + "сделки", + "сделок", + ])}`, + dealTier.asOf, + ] + .filter(Boolean) + .join(" · ") : "—", bars: dealTier ? dealTier.bars : [], nav: 1, diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index af89b100..6c2aa52e 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -1,6 +1,41 @@ export const ROSREESTR_REQUEST_URL = "https://rosreestr.gov.ru/eservices/request_info_from_egrn/"; +const ROMAN_QUARTER = ["I", "II", "III", "IV"] as const; + +/** + * #2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ ДКП-сделки. + * + * Гранулярность — квартал, и это не округление ради красоты: Росреестр + * публикует ДКП пачками, deal_date = первый день квартала (бэкенд помечает это + * `date_precision: "quarter"`, #1995). Прод-замер 2026-08-12: 96 974 сделки, + * 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно {01,04,07,10}. + * Поэтому «223 дня назад» было бы ложной точностью в сторону состаривания: + * сделка из пачки «2026-01-01» могла случиться и 31 марта. «по I кв. 2026» — + * ровно то, что известно, и ни бита сверх. + * + * «по» (а не «за») намеренно: выборка обычно накрывает несколько кварталов, + * и подпись называет ВЕРХНЮЮ границу свежести, а не период целиком. + * + * @param iso ISO-дата свежайшей сделки ВЫБОРКИ (не всей таблицы). + * @param precision 'day' у источника с настоящей датой; иначе квартал. + * @returns null, если датировать нечего — подпись тогда не рисуется вовсе. + */ +export function dealsAsOfLabel( + iso: string | null | undefined, + precision?: "day" | "quarter" | null, +): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + // ISO date-only ("2026-01-01") парсится как UTC-полночь — читаем UTC-поля, + // иначе в отрицательных зонах квартал уезжает на предыдущий. + if (precision === "day") { + return `по ${String(d.getUTCMonth() + 1).padStart(2, "0")}.${d.getUTCFullYear()}`; + } + return `по ${ROMAN_QUARTER[Math.floor(d.getUTCMonth() / 3)]} кв. ${d.getUTCFullYear()}`; +} + export async function openRosreestrWithAddress(address: string): Promise { try { await navigator.clipboard.writeText(address); diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 38ecd503..c136e129 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -110,6 +110,10 @@ export interface AnalogLot { // 'T0_per_house' — kadastr_num exact match (currently not available in open dataset) // 'T1_per_street' — street-level only (default for all rosreestr deals) tier?: string | null; + // #1995 (бэкенд уже слал, фронт игнорировал; поднято #2846): честная точность + // listing_date. 'quarter' — rosreestr: дата = первый день КВАРТАЛА, а не день + // сделки. 'day' — объявления. undefined у старых оценок и когда source неизвестен. + date_precision?: "day" | "quarter" | null; // ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional + // nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем // пины с null lat/lon. @@ -153,7 +157,12 @@ export interface DkpCorridor { low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) median_ppm2: number; // медиана ₽/м² high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) - period_months: number; // окно поиска сделок + period_months: number; // окно ПОИСКА сделок — НЕ возраст данных + // #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность + // — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому + // «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки, + // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется. + latest_deal_date?: string | null; } export interface AggregatedEstimate { From 3cd7e0a9c4a9c61644760886158ccd6461b9f5fb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 19:36:20 +0000 Subject: [PATCH 05/60] =?UTF-8?q?fix(tradein/sber):=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B6=20=D0=BC=D0=B5=D1=80=D0=B8=D1=82=20=D0=BE?= =?UTF-8?q?=D1=82=D1=81=D1=82=D0=B0=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8C?= =?UTF-8?q?=20(#2846)=20(#2849)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/core/config.py | 10 +- tradein-mvp/backend/app/services/estimator.py | 26 +- .../backend/app/services/sber_index.py | 16 +- .../app/tasks/sber_freshness_monitor.py | 294 +++++++++----- .../tests/test_alerts_become_events.py | 50 ++- .../tests/test_estimator_audit_fixes.py | 44 +-- .../tests/test_sber_freshness_monitor.py | 364 +++++++++++++----- 7 files changed, 541 insertions(+), 263 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 1c2ad8c1..8d6cf935 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -599,9 +599,13 @@ class Settings(BaseSettings): cian_valuation_max_rub: float = 500_000_000 # ── #audit-5: data-age guards ───────────────────────────────────────────── - # sber_index_max_age_days: максимальный допустимый возраст последнего месяца - # СберИндекс-серии (дней). Если latest месяц старее — логируем warning. - sber_index_max_age_days: int = 35 + # #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению + # (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток), + # guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно + # место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог + # берёт из такта самой загрузки (scrape_schedules.default_params.interval_days), + # так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться. + # extra="ignore" в model_config защищает от startup-краха на leftover env var. # avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка # на тонком рынке (thin_market=True в AvitoImvSummary) + warning. avito_imv_thin_market_threshold: int = 10 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 9acab16d..acb7f440 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1565,7 +1565,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: """#794: monthly {period_month: index_value} for region from sber_price_index. Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error. - #audit-5a: если latest месяц серии старее sber_index_max_age_days → warning. + + #2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest + периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по + построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на + закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых + наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени — + нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip + (event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor, + и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт), + а не календарь. """ for dash in SBER_COEFF_DASHBOARDS: try: @@ -1593,21 +1602,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} if not series: continue - # #audit-5a: data-age guard — предупреждаем о stale СберИндексе. - latest = max(series) - today = datetime.now(tz=UTC).date() - age_days = (today - latest).days - if age_days > settings.sber_index_max_age_days: - logger.warning( - "sber_index stale #audit-5a: latest=%s age=%d days" - " (> sber_index_max_age_days=%d) region=%s dash=%s" - " — time-adjustment may be outdated", - latest.isoformat(), - age_days, - settings.sber_index_max_age_days, - region, - dash, - ) return series return {} diff --git a/tradein-mvp/backend/app/services/sber_index.py b/tradein-mvp/backend/app/services/sber_index.py index 7a4d646c..8331b2d2 100644 --- a/tradein-mvp/backend/app/services/sber_index.py +++ b/tradein-mvp/backend/app/services/sber_index.py @@ -303,6 +303,18 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st #1348: blocking psycopg work — must run via asyncio.to_thread, never directly on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard). + + #2846: `fetched_at` НЕ переписывается при конфликте. Забор идёт ВСЕЙ серией + (limit=1000&offset=0, отсечки по периоду нет), поэтому `fetched_at = now()` в + DO UPDATE ставил одну и ту же метку всем строкам ряда — на проде все 639 строк + несли время последнего прогона, включая период 2017-01. Как признак свежести + колонка была пуста. Теперь она означает «когда мы ВПЕРВЫЕ увидели этот период», + то есть по ней измеряется ТАКТ ПУБЛИКАЦИИ источника (min(fetched_at) по новым + периодам). Ретроспективу это не возвращает: у 639 уже лежащих строк метка + 2026-08-06 и она останется — такт публикации до этого PR невосстановим. + Времени последней ЗАГРУЗКИ колонка больше не хранит; оно и не нужно — + scrape_runs(source='sber_index_pull') хранит его точнее (с errors/upserted), + и именно оттуда его берёт tasks/sber_freshness_monitor. """ for city_label, period_month, segment, dash, value in rows_to_upsert: db.execute( @@ -323,8 +335,8 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st ON CONFLICT (city, period_month, dashboard) DO UPDATE SET index_value_rub_m2 = EXCLUDED.index_value_rub_m2, - segment = EXCLUDED.segment, - fetched_at = now() + segment = EXCLUDED.segment + -- fetched_at НЕ трогаем (#2846): она = «впервые увидели период». """ ), { diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 487179d5..cde46a41 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -1,56 +1,68 @@ -"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1. +"""Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода). -Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет -СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning, -когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба -`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует -данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит -(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness -видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. +ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846). -Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и -поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага -публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. +Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток +(sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по +построению: `period_month` — метка ПЕРВОГО числа месяца, поэтому на закрытии месяца +возрасту уже ≥30; плюс собственный лаг публикации источника. За 31 сутки прямых +наблюдений монитора (07-13 … 08-12, scrape_runs.counters) возраст лежал в 46..76 и +НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени — ноль бит. -#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с -LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING -событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его -застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor) -писал ERROR с самого начала — расходилась только эта джоба. +Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор +мерил не источник, а нашу же пилу. Миграция 212 (такт 28 → 7) обещала потолок +возраста ≈46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне +загрузки шестидневной давности (08-06, errors=0, upserted=639) — источник просто не +опубликовал июль. Двенадцать суток подряд (08-01 … 08-12) монитор писал ERROR при +исправной загрузке. Потолок 53 держится, только если источник публикует строго +помесячно; он не публикует. -ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов -монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего -собственного такта загрузки: - 13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65 - 17.07 alert=0 age 46 latest=июнь (день загрузки) -Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же -считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок -46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать -такое до ERROR без починки такта значило бы завести ежедневное ложное событие на -две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ -такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает -«источник/загрузка встали», а не «мы давно не ходили». +ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду +нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН +максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» — это вопрос +«давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода: -Порог алерта (документирование выбора): - Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д). - Монитор: age > sber_index_max_age_days + lag_allowance. - lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг - публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл - ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток. - Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста - ~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл - поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри - рабочего диапазона — если такт загрузки будут менять, пересчитай потолок - (пол + interval_days) и сверь с 60. + последний полный прогон свежий → наш max == max источника → источник не публиковал, + молчание ПРАВИЛЬНОЕ (возраст = лаг источника); + последний полный прогон старый → мы не забрали → тревога про ЗАГРУЗЧИК. -Задача синхронная (DB-only, один SELECT max(period_month)) — запускается -kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в -run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ -функция evaluate_sber_freshness() (frozen-now тестируется без БД). +ЛОВУШКА: `status='done'` НЕ означает успех — прогон id=37 (2026-05-31) имеет +{errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий +3 табло × 3 региона прошли: errors — счётчик по всему прогону). -Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи -достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего -оценивать). +ПОРОГ — не круглое число, а такт самой загрузки: `scrape_schedules.default_params +.interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик +запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с +тактом порог не может: поменяли такт — порог поехал следом. Тревога после +MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети) +поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14 +суток; на прод-истории такое состояние ДОСТИЖИМО — разрывы между полными прогонами +были 14.8 и 20 суток (05-31→06-15 и 07-17→08-06). + +ПО ТАБЛО, А НЕ ПО max() ВСЕЙ ТАБЛИЦЫ. Оценщик берёт ПЕРВОЕ НЕПУСТОЕ табло из +estimator.SBER_COEFF_DASHBOARDS; у real_estate_deals latest=2026-06, у +dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблице маскирует +отставшее табло, поэтому монитор идёт тем же порядком, что и оценщик, и берёт ту же +серию — список импортируется из estimator, дублировать его тут нельзя. + +ЧЕГО ЭТОТ МОНИТОР НЕ ЛОВИТ (осознанно, #2846). Если источник ЗАМОЛЧИТ НАВСЕГДА, а +загрузка останется исправной — монитор промолчит: по нашим данным «источник не +публиковал 2 месяца» неотличимо от «источник публикует раз в 2 месяца». Такт +публикации источника ретроспективно невосстановим — его затёр апсерт +(sber_index.py ставил fetched_at=now() всем строкам серии). С этого PR fetched_at +не переписывается при конфликте и означает «когда мы ВПЕРВЫЕ увидели этот период», +т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал» +имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026). + +ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с +LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще. + +Задача синхронная (DB-only) — запускается kit-scheduler'ом через +product_handlers._job_sber_freshness_monitor в run_in_executor. Вердикт считает +ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now, тестируется без БД). + +Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы). mark_failed +только если у оценщика вообще нет серии (нечего оценивать). """ from __future__ import annotations @@ -62,153 +74,239 @@ from datetime import UTC, date, datetime from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings from app.services import scrape_runs as runs_mod +from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION logger = logging.getLogger(__name__) __all__ = [ - "DEFAULT_LAG_ALLOWANCE_DAYS", + "DEFAULT_PULL_INTERVAL_DAYS", + "MISSED_PULL_CYCLES", + "SBER_FRESHNESS_PULL_SOURCE", "SberFreshnessVerdict", "check_sber_freshness", "evaluate_sber_freshness", ] -# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate -# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days. -DEFAULT_LAG_ALLOWANCE_DAYS = 25 +# Джоба-загрузчик, чей такт и успешность мы и мониторим. +SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull" -# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями -# sber_price_index для областного уровня. -SBER_MONITOR_CITY = "Свердловская область" +# Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг +# окна (поглощаем), 2 = загрузка встала (алерт). +MISSED_PULL_CYCLES = 2 -_LATEST_SBER_PERIOD_SQL = text(""" +# Фолбэк, если в scrape_schedules нет строки/ключа interval_days (миграция 212 ставит 7). +DEFAULT_PULL_INTERVAL_DAYS = 7 + +_LATEST_PERIOD_SQL = text(""" SELECT max(period_month) AS latest FROM sber_price_index WHERE city = CAST(:city AS text) + AND dashboard = CAST(:dash AS text) -- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка -- (новостройки) = направленно неверная коррекция. Зеркалит фильтр -- estimator._load_sber_index_series. AND (segment IS NULL OR segment ILIKE '%вторичн%') """) +# Последний ЗАВЕДОМО ПОЛНЫЙ прогон загрузчика. status='done' сюда не входит намеренно: +# прогон id=37 имеет done при {errors: 9, upserted: 0}. Сравнения — jsonb-ные, без +# CAST(... AS int): counters других источников планировщик может отфильтровать позже +# каста, а не раньше, и нечисловое значение уронило бы запрос. Для jsonb-чисел +# оператор > численный. +_LAST_COMPLETE_PULL_SQL = text(""" + SELECT max(finished_at) AS last_pull + FROM scrape_runs + WHERE source = CAST(:src AS text) + AND counters @> CAST('{"errors": 0}' AS jsonb) + AND counters -> 'upserted' > CAST('0' AS jsonb) +""") + +# Такт загрузки — из той же строки, по которой планировщик считает next_run_at. +_PULL_INTERVAL_SQL = text(""" + SELECT default_params ->> 'interval_days' AS interval_days + FROM scrape_schedules + WHERE source = CAST(:src AS text) +""") + @dataclass(frozen=True) class SberFreshnessVerdict: - """Вердикт свежести СберИндекса по max(period_month).""" + """Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта.""" latest_period: date - age_days: int + age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги + pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было + max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки stale: bool def evaluate_sber_freshness( latest_period: date, now: datetime, - max_age_days: int, + *, + last_complete_pull_at: datetime | None, + pull_interval_days: int, ) -> SberFreshnessVerdict: - """Чистая логика: устарел ли latest период СберИндекса. + """Чистая логика: отстала ли загрузка от собственного такта. - stale = age_days > max_age_days, где age_days = now.date() - latest_period. - `max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance), - вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД. + stale = полных прогонов не было ВООБЩЕ, либо последний старше + MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в + вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш + max(period_month) равен максимуму источника по построению, и его возраст — это + лаг ПУБЛИКАЦИИ, на который мы повлиять не можем. """ age_days = (now.date() - latest_period).days - stale = age_days > max_age_days + max_pull_lag_days = MISSED_PULL_CYCLES * pull_interval_days + if last_complete_pull_at is None: + return SberFreshnessVerdict(latest_period, age_days, -1, max_pull_lag_days, True) + pull_lag_days = (now - last_complete_pull_at).days return SberFreshnessVerdict( latest_period=latest_period, age_days=age_days, - stale=stale, + pull_lag_days=pull_lag_days, + max_pull_lag_days=max_pull_lag_days, + stale=pull_lag_days > max_pull_lag_days, ) +def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None: + """Табло, которое возьмёт оценщик, и его latest период. + + Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло + из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло. + """ + for dash in SBER_COEFF_DASHBOARDS: + row = db.execute( + _LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash} + ).first() + latest = row.latest if row is not None else None + if latest is not None: + return dash, latest + return None + + +def _pull_interval_days(db: Session) -> int: + """Такт загрузчика из scrape_schedules (фолбэк DEFAULT_PULL_INTERVAL_DAYS).""" + row = db.execute(_PULL_INTERVAL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}).first() + raw = row.interval_days if row is not None else None + try: + return int(raw) if raw is not None else DEFAULT_PULL_INTERVAL_DAYS + except (TypeError, ValueError): + logger.warning( + "sber freshness: interval_days=%r в scrape_schedules нечисловой — беру %d", + raw, + DEFAULT_PULL_INTERVAL_DAYS, + ) + return DEFAULT_PULL_INTERVAL_DAYS + + def check_sber_freshness( db: Session, run_id: int, params: dict | None = None, # type: ignore[type-arg] now: datetime | None = None, ) -> dict[str, int]: - """Проверить свежесть СберИндекса по max(period_month) и алертить при staleness. + """Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании. Sync (вызывается scheduler-триггером в executor, как check_deals_freshness). - Читает один SELECT max(period_month) вторичного сегмента по региону, считает - вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops) - и финализирует run. + Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона + sber_index_pull, такт загрузки из scrape_schedules. Вердикт — чистой функцией. - Params (default_params jsonb): - lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25). - `now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC). + `params` больше ничего не настраивает: порог берётся из такта самой загрузки + (унаследованный default_params.lag_allowance_days=25 монитора игнорируется — + он кодировал мёртвый календарный порог). `now` инъектируется в тестах. - Returns counters {latest_year, latest_month, age_days, alert}. - mark_failed только если sber_price_index пуста/недоступна (нечего оценивать); + Returns counters {latest_year, latest_month, age_days, pull_lag_days, + max_pull_lag_days, alert}. + mark_failed только если у оценщика нет серии вообще (нечего оценивать); при алерте прогон помечается done (это монитор, не сбой джобы). """ - params = params or {} now = now or datetime.now(UTC) counters: dict[str, int] = { "latest_year": 0, "latest_month": 0, "age_days": 0, + "pull_lag_days": -1, + "max_pull_lag_days": 0, "alert": 0, } try: runs_mod.update_heartbeat(db, run_id, counters) - row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first() - latest: date | None = row.latest if row is not None else None - if latest is None: + found = _load_estimator_dashboard(db) + if found is None: # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. logger.error( - "sber freshness: sber_price_index пуст/недоступен для region=%s " - "(вторичка) — оценить свежесть нельзя", - SBER_MONITOR_CITY, + "sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк " + "для region=%s (вторичка); оценить нечего", + list(SBER_COEFF_DASHBOARDS), + SBER_TIME_ADJUST_REGION, ) runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters) return counters - lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS)) - max_age_days = settings.sber_index_max_age_days + lag_days - verdict = evaluate_sber_freshness(latest, now, max_age_days) + dashboard, latest = found + last_pull_row = db.execute( + _LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE} + ).first() + last_complete_pull_at = last_pull_row.last_pull if last_pull_row is not None else None + verdict = evaluate_sber_freshness( + latest, + now, + last_complete_pull_at=last_complete_pull_at, + pull_interval_days=_pull_interval_days(db), + ) counters = { "latest_year": latest.year, "latest_month": latest.month, "age_days": verdict.age_days, + "pull_lag_days": verdict.pull_lag_days, + "max_pull_lag_days": verdict.max_pull_lag_days, "alert": int(verdict.stale), } if verdict.stale: - # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) — - # 9 срабатываний на проде дали ноль событий. См. докстринг модуля. + # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR). logger.error( - "sber freshness: max(period_month)=%s устарел на %d дней " - "(> порога %d = sber_index_max_age_days %d + lag %d); " - "СберИндекс time-adjustment ДКП-сделок мог отстать — " - "проверь sber_index_pull и доступность новых периодов источника", + "sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон " + "%s (%s суток назад, порог %d = %d такта × %d суток; " + "status='done' с errors>0 за успех НЕ считается). " + "Наш max(period_month)=%s (табло %s) мог разойтись с источником — " + "проверь sber_index_pull: планировщик, сеть, /api/sowa 404", + last_complete_pull_at.isoformat() if last_complete_pull_at else "НИ РАЗУ", + verdict.pull_lag_days if verdict.pull_lag_days >= 0 else "∞", + verdict.max_pull_lag_days, + MISSED_PULL_CYCLES, + verdict.max_pull_lag_days // MISSED_PULL_CYCLES, latest, - verdict.age_days, - max_age_days, - settings.sber_index_max_age_days, - lag_days, + dashboard, ) else: logger.info( - "sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) " - "region=%s — алерта нет", + "sber freshness: загрузка в такте — последний полный прогон %d суток назад " + "(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен " + "максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, " + "не наше отставание — алерта нет", + verdict.pull_lag_days, + verdict.max_pull_lag_days, latest, + dashboard, verdict.age_days, - max_age_days, - SBER_MONITOR_CITY, ) runs_mod.mark_done(db, run_id, counters) logger.info( - "check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d", + "check_sber_freshness run_id=%d done: latest=%s dash=%s alert=%d " + "pull_lag_days=%d age_days=%d", run_id, latest, + dashboard, counters["alert"], + counters["pull_lag_days"], counters["age_days"], ) return counters diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py index ae084d93..22324746 100644 --- a/tradein-mvp/backend/tests/test_alerts_become_events.py +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -102,14 +102,32 @@ def test_harness_itself_drops_warnings() -> None: class _FakeMonitorDB: - """Session-мок мониторов свежести: один SELECT max(...).""" + """Session-мок мониторов свежести. - def __init__(self, latest: date | None) -> None: + Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает + ещё время последнего ПОЛНОГО прогона загрузки и её такт — именно они, а не + календарный возраст периода, решают, быть ли тревоге. + """ + + def __init__( + self, + latest: date | None, + last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC), + interval_days: str = "7", + ) -> None: self._latest = latest + self._last_pull = last_pull + self._interval_days = interval_days def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + sql = str(stmt) result = MagicMock() - result.first.return_value = MagicMock(latest=self._latest) + if "scrape_runs" in sql: + result.first.return_value = MagicMock(last_pull=self._last_pull) + elif "scrape_schedules" in sql: + result.first.return_value = MagicMock(interval_days=self._interval_days) + else: + result.first.return_value = MagicMock(latest=self._latest) return result def rollback(self) -> None: @@ -122,10 +140,13 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None: monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None) -def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: - """Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие.""" +def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Загрузка встала (полный прогон 20 суток назад при такте 7) → событие. + + 20 суток — реальный разрыв прод-истории между полными прогонами 07-17 и 08-06. + """ _patch_runs(monkeypatch, sber_mon) - db = _FakeMonitorDB(date(2026, 6, 1)) + db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC)) with glitchtip_events() as events: out = sber_mon.check_sber_freshness( db, # type: ignore[arg-type] @@ -136,19 +157,24 @@ def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: assert out["alert"] == 1 assert any( "sber freshness" in t for t in event_texts(events) - ), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает" + ), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает" -def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: - """Свежие данные — ни одного события (иначе алерт-усталость).""" +def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет. + + Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR: + возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость + от таких событий и делает настоящий отказ незаметным. + """ _patch_runs(monkeypatch, sber_mon) - db = _FakeMonitorDB(date(2026, 6, 1)) + db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон) with glitchtip_events() as events: out = sber_mon.check_sber_freshness( db, # type: ignore[arg-type] run_id=2, params={}, - now=datetime(2026, 6, 20, tzinfo=UTC), + now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC), ) assert out["alert"] == 0 assert event_texts(events) == [] @@ -165,7 +191,7 @@ def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None params={}, now=datetime(2026, 8, 6, tzinfo=UTC), ) - assert any("sber_price_index пуст" in t for t in event_texts(events)) + assert any("у оценщика нет серии" in t for t in event_texts(events)) def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index 6935c5fb..f6cb2430 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -331,18 +331,24 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None: # --------------------------------------------------------------------------- -# Fix 5a — sber staleness warning +# Fix 5a — per-estimate sber staleness warning УДАЛЁН (#2846) +# +# Guard сравнивал возраст latest периода с sber_index_max_age_days=35. Такой +# возраст недостижим по построению (period_month — метка первого числа + лаг +# публикации ⇒ пол 46 суток), поэтому на проде warning писался у КАЖДОЙ оценки +# и не нёс ни бита. Пара прежних тестов зеленела только на фикстуре с «свежим» +# месяцем, которого в реальной серии не бывает. Свежесть теперь мерит одно место — +# tasks/sber_freshness_monitor, по отставанию ЗАГРУЗКИ. # --------------------------------------------------------------------------- -def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None: - """_load_sber_index_series логирует warning при stale серии.""" +def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None: + """Серия отдаётся как есть; календарного warning'а в горячем пути больше нет.""" import logging from app.services.estimator import _load_sber_index_series - # Серия с единственным месяцем 2 года назад - stale_month = date(2024, 1, 1) + stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"period_month": stale_month, "index_value_rub_m2": 100_000.0} @@ -351,33 +357,9 @@ def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None with caplog.at_level(logging.WARNING, logger="app.services.estimator"): series = _load_sber_index_series(mock_db, region="Свердловская область") - assert len(series) == 1 - assert stale_month in series - # Warning о staleness должен быть залогирован + assert series == {stale_month: 100_000.0} stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] - assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}" - - -def test_fix5a_fresh_sber_no_warning(caplog: pytest.LogCaptureFixture) -> None: - """_load_sber_index_series НЕ логирует warning при свежей серии.""" - import logging - - from app.services.estimator import _load_sber_index_series - - # Текущий месяц (age=0..30 дней — точно свежее 35-дневного порога). - today = datetime.now(tz=UTC).date() - fresh_month = today.replace(day=1) # 1-е число ТЕКУЩЕГО месяца - mock_db = MagicMock() - mock_db.execute.return_value.mappings.return_value.all.return_value = [ - {"period_month": fresh_month, "index_value_rub_m2": 128_000.0} - ] - - with caplog.at_level(logging.WARNING, logger="app.services.estimator"): - series = _load_sber_index_series(mock_db, region="Свердловская область") - - assert len(series) == 1 - stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] - assert not stale_msgs, f"Не ожидали stale warning для свежей серии, caplog: {caplog.text}" + assert not stale_msgs, f"per-estimate guard вернулся, caplog: {caplog.text}" # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py index 726d991e..fafe6f41 100644 --- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py +++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py @@ -1,13 +1,20 @@ -"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1. +"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846). Покрывает: - 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): - - fresh: age <= max_age_days (алерта нет); - - stale: age > max_age_days (алерт); - - граница порога (== max_age_days → нет алерта; +1 день → алерт). - 2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag). - 3. Свойства миграции 180 (по образцу test_deals_freshness_monitor). - 4. Регистрацию в kit product_handlers (registry остаётся зелёным). + 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте / + загрузка встала / полных прогонов не было вовсе / граница порога. + 2. check_sber_freshness с FakeDB — прод-реплей 2026-08-12 и двусторонность: + при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой. + 3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы + отставшее табло). + 4. Свойства миграций 180/212 + регистрацию в kit product_handlers. + +Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index): + latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток), + dinamika-tsen-obyavlenii = 2026-05-01 (103); + последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639); + такт загрузки interval_days = 7; + прогон id=37 (05-31) — status='done' при {errors: 9, upserted: 0}, за успех НЕ считается. """ from __future__ import annotations @@ -22,7 +29,6 @@ import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.core.config import settings from app.services.product_handlers import build_product_handlers from app.tasks import sber_freshness_monitor as mon @@ -30,69 +36,152 @@ _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql" _MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql" -# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12). -_MAY_2026 = date(2026, 5, 1) - - -# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── +# Прод-состояние 2026-08-12. +_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток +_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103 +_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639 +_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days +_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера def _now(y: int, m: int, d: int) -> datetime: return datetime(y, m, d, tzinfo=UTC) -def test_fresh_within_max_age() -> None: - """age=30 ≤ max_age_days=60 — свежий, алерта нет.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60) - assert v.stale is False - assert v.age_days == 30 - assert v.latest_period == _MAY_2026 +# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── -def test_stale_beyond_max_age() -> None: - """Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60) - assert v.stale is True +def test_loader_in_cadence_no_alert_even_at_age_72() -> None: + """Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим. + + После полного прогона наш max(period_month) равен максимуму источника ПО + ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток — лаг ПУБЛИКАЦИИ Сбера, + а не наше отставание. + """ + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) assert v.age_days == 72 + assert v.pull_lag_days == 6 + assert v.stale is False + + +def test_loader_stalled_alerts_at_the_same_age() -> None: + """Тот же возраст периода, но полный прогон 20 суток назад → тревога. + + 20 суток — реальный разрыв прод-истории (07-17 → 08-06) при пороге 2×7=14. + """ + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_now(2026, 7, 23), + pull_interval_days=_PULL_INTERVAL, + ) + assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше + assert v.pull_lag_days == 20 + assert v.max_pull_lag_days == 14 + assert v.stale is True + + +def test_no_complete_pull_ever_alerts() -> None: + """Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=None, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.stale is True + assert v.pull_lag_days == -1 def test_threshold_boundary_exact_no_alert() -> None: - """Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >).""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60) - assert v.age_days == 60 + """Ровно на пороге (2 такта) алерта ещё нет — строгое >.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL), + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.pull_lag_days == 14 assert v.stale is False -def test_threshold_boundary_next_day_alert() -> None: - """Порог + 1 день (age=61) — первый алерт.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60) - assert v.age_days == 61 +def test_threshold_boundary_next_day_alerts() -> None: + """Порог + 1 сутки — первый алерт (два такта подряд пропущены).""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1), + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.pull_lag_days == 15 assert v.stale is True +def test_threshold_follows_pull_cadence() -> None: + """Порог — производная такта загрузки, а не константа: такт 28 → порог 56.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_now(2026, 7, 23), # 20 суток + pull_interval_days=28, + ) + assert v.max_pull_lag_days == 56 + assert v.stale is False # при месячном такте 20 суток — норма + + # ── check_sber_freshness (FakeDB) ───────────────────────────────────────────── class _Row: - def __init__(self, latest: date | None) -> None: - self.latest = latest + def __init__(self, **kw: Any) -> None: + self.__dict__.update(kw) class _FakeResult: - def __init__(self, latest: date | None) -> None: - self._latest = latest + def __init__(self, row: _Row | None) -> None: + self._row = row - def first(self) -> _Row: - return _Row(self._latest) + def first(self) -> _Row | None: + return self._row class _FakeDB: - def __init__(self, latest: date | None) -> None: - self._latest = latest + """Отвечает на три запроса монитора; пригоден и для старой версии кода. + + Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра — на такой + запрос отдаём максимум по всем табло, ровно как это делал бы Postgres. + """ + + def __init__( + self, + latest_by_dash: dict[str, date], + last_pull: datetime | None = _LAST_FULL_PULL, + interval_days: str | None = str(_PULL_INTERVAL), + ) -> None: + self._latest_by_dash = latest_by_dash + self._last_pull = last_pull + self._interval_days = interval_days self.rolled_back = False + self.asked_dashboards: list[str] = [] def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: - return _FakeResult(self._latest) + sql = str(stmt) + params = params or {} + if "scrape_runs" in sql: + return _FakeResult(_Row(last_pull=self._last_pull)) + if "scrape_schedules" in sql: + return _FakeResult(_Row(interval_days=self._interval_days)) + if "dash" in params: + self.asked_dashboards.append(params["dash"]) + return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"]))) + # Старый монитор: max(period_month) по всей таблице. + latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None + return _FakeResult(_Row(latest=latest)) def rollback(self) -> None: self.rolled_back = True @@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: return calls -def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # @2026-05-31: age=30 ≤ 35+25=60 → нет алерта. - out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type] - assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0} - assert calls["done"] == out - assert calls["failed"] is None +def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB: + """Прод-состояние 2026-08-12 (оба табло вторички).""" + return _FakeDB( + {"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026}, + last_pull=last_pull, + ) -def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prod_replay_healthy_loader_silent_source_no_alert( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина. + + На main монитор мерил календарь (72 > 60) и писал ERROR — двенадцатые сутки + подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации + Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же. + """ calls = _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # @2026-07-12 (прод): age=72 > 60 → алерт. - out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type] - assert out["alert"] == 1 + db = _prod_db() + out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["age_days"] == 72 - assert out["latest_month"] == 5 - # Монитор НЕ падает при алерте — прогон done, а не failed. + assert out["alert"] == 0 assert calls["done"] == out assert calls["failed"] is None -def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: +def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None: + """Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой. + + На main оба состояния дают alert=1 (вердикт зависит только от календаря) — + сторож не умеет зеленеть, что и было исходным дефектом. + """ + _patch_runs(monkeypatch) + healthy = mon.check_sber_freshness( + _prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type] + run_id=2, + params={}, + now=_PROD_NOW, + ) + stalled = mon.check_sber_freshness( + _prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type] + run_id=3, + params={}, + now=_PROD_NOW, + ) + assert healthy["age_days"] == stalled["age_days"] == 72 + assert (healthy["alert"], stalled["alert"]) == (0, 1) + + +def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None: + """Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит. + + Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт — + SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь + ровно то состояние, что дал бы прод с одним лишь id=37. + """ + _patch_runs(monkeypatch) + out = mon.check_sber_freshness( + _prod_db(last_pull=None), # type: ignore[arg-type] + run_id=4, + params={}, + now=_PROD_NOW, + ) + assert out["alert"] == 1 + assert out["pull_lag_days"] == -1 + + +def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None: + """Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице).""" + _patch_runs(monkeypatch) + db = _prod_db() + out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert db.asked_dashboards[0] == "real_estate_deals" + assert (out["latest_year"], out["latest_month"]) == (2026, 6) + + +def test_falls_back_to_next_dashboard_when_first_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Первое табло пусто → берём следующее, как и оценщик.""" + _patch_runs(monkeypatch) + db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026}) + out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["latest_month"] == 5 + assert out["age_days"] == 103 + + +def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одного табло с данными — оценивать нечего, это сбой монитора.""" calls = _patch_runs(monkeypatch) - db = _FakeDB(None) - out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type] + out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["alert"] == 0 assert calls["done"] is None assert calls["failed"] is not None -def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None: +def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + """Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь. + + Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180). + С любым её значением вердикт один и тот же — порог берётся из такта загрузки. + """ _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено. - out = mon.check_sber_freshness( - db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12) - ) # type: ignore[arg-type] - assert out["alert"] == 1 + base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type] + tweaked = mon.check_sber_freshness( + _prod_db(), # type: ignore[arg-type] + run_id=9, + params={"lag_allowance_days": 0}, + now=_PROD_NOW, + ) + assert base["alert"] == tweaked["alert"] == 0 -def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None: - """Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS.""" +def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None: + """Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет.""" _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS - # Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт. - exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold) - out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type] - assert out_exact["age_days"] == threshold - assert out_exact["alert"] == 0 - out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type] - assert out_over["alert"] == 1 + db = _FakeDB( + {"real_estate_deals": _JUN_2026}, + last_pull=_now(2026, 7, 23), # 20 суток + interval_days="28", + ) + out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["max_pull_lag_days"] == 56 + assert out["alert"] == 0 + + +def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем.""" + _patch_runs(monkeypatch) + db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None) + out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS # ── Миграция 180 ────────────────────────────────────────────────────────────── @@ -208,22 +375,18 @@ def test_migration_180_window_9_to_10_utc() -> None: assert re.search(r"\b10\b", sql), "window_end_hour 10 missing" -def test_migration_180_lag_allowance_25() -> None: - sql = _MIGRATION_180.read_text("utf-8") - assert "lag_allowance_days" in sql - assert "25" in sql - - def test_migration_180_no_psycopg_trap() -> None: sql = _MIGRATION_180.read_text("utf-8") assert not re.search(r":\w+::", sql) -# ── Миграция 212: такт загрузки не должен пересекать порог монитора ─────────── +# ── Миграция 212: такт загрузки = источник порога ───────────────────────────── # -# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74 -# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника. -# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора. +# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят +# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при +# полном прогоне шестидневной давности, потолок 53 держался бы только если бы +# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода +# не должен расходиться с тактом, который сеет миграция. def test_migration_212_makes_pull_cadence_weekly() -> None: @@ -234,23 +397,22 @@ def test_migration_212_makes_pull_cadence_weekly() -> None: assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type) -def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None: - """Инвариант: пол возраста + такт загрузки < порога монитора. - - Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог = - sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 — запас - 7 суток. При прежних 28: 46+28=74 > 60 — тревога каждый цикл, что и наблюдали. - """ +def test_default_pull_interval_matches_migration_212() -> None: + """Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой.""" interval_days = int( - re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) - ) - observed_floor_days = 46 - threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS - assert observed_floor_days + interval_days < threshold, ( - f"такт {interval_days}д даёт потолок возраста " - f"{observed_floor_days + interval_days}д при пороге {threshold}д — " - "монитор снова будет мерить наш такт, а не застой источника" + re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr] ) + assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days + + +# ── Один порог, а не два ────────────────────────────────────────────────────── + + +def test_no_second_calendar_threshold_in_settings() -> None: + """#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться.""" + from app.core.config import settings + + assert not hasattr(settings, "sber_index_max_age_days") # ── Регистрация в kit registry ───────────────────────────────────────────────── From 13ce1045bb2b2b9b1999755b0b360ea630bbdb5d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:17:10 +0000 Subject: [PATCH 06/60] =?UTF-8?q?fix(tradein/estimate):=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2=D0=BE=D0=B9=20IMV-=D1=8F=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=80=D0=B8=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BA=20=D0=B1=D0=B0=D0=B7=D0=B8=D1=81=D1=83=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=BE=D0=BD=D1=82=D0=B0=20=D0=BA=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D1=8B=20(#2677)=20(#2816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 55 ++++- .../test_2677_house_anchor_repair_basis.py | 226 ++++++++++++++++++ 2 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index acb7f440..89b9e84b 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -488,6 +488,32 @@ def _repair_coefficient(repair_state: str | None) -> float: return _REPAIR_COEF.get(repair_state, 1.0) +# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state. +_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v} + + +def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float: + """#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а. + + Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом + (`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана + к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать + и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента + с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до + ×1.081, а сработавший blend возвращает половину этих −6% обратно вверх. + + Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная + эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ + применил к медиане, — чинится расхождение базисов, а не калибровка. + + Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение). + """ + base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or "")) + if base <= 0: + return 1.0 + return _repair_coefficient(target_repair) / base + + # ── Asking→sold correction ratio lookup (#648 Stage 3) ────────────────────── # Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент # ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator @@ -1165,7 +1191,11 @@ def _fetch_house_imv_anchor( house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress). Returns dict {recommended_price, lower_price, higher_price, market_count, - rooms, area_m2} или None. + rooms, area_m2, renovation_type} или None. + + #2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito. + Без него якорь нельзя привести к базису ремонта оцениваемой квартиры + (см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op. """ if target_house_id is None: return None @@ -1175,7 +1205,7 @@ def _fetch_house_imv_anchor( text( """ SELECT recommended_price, lower_price, higher_price, - market_count, rooms, area_m2 + market_count, rooms, area_m2, renovation_type FROM house_imv_evaluations WHERE house_id = CAST(:hid AS bigint) AND recommended_price > 0 @@ -3114,6 +3144,7 @@ def _price_from_inputs( anchor_total: int | None = None anchor_higher: int | None = None anchor_label: str | None = None + anchor_repair_note: str = "" if imv_anchor is not None and imv_anchor.get("recommended_price"): anchor_total = int(imv_anchor["recommended_price"]) anchor_higher = ( @@ -3132,6 +3163,22 @@ def _price_from_inputs( _imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold ), ) + # #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его + # не правим), а в blend уходит якорь, приведённый к базису ремонта target'а — + # тому же, в котором median_price уже посчитан выше (repair_coef). + _rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state) + if _rep_k != 1.0: + anchor_total = round(anchor_total * _rep_k) + anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None + anchor_repair_note = ", приведённой к состоянию ремонта квартиры" + logger.info( + "imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f" + " → anchor %d", + imv_anchor.get("renovation_type"), + repair_state, + _rep_k, + anchor_total, + ) elif imv_eval is not None and imv_eval.recommended_price: anchor_total = int(imv_eval.recommended_price) anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None @@ -3181,8 +3228,8 @@ def _price_from_inputs( median_price = new_median median_ppm2 = new_ppm2 explanation = (explanation or "") + ( - f" Оценка скорректирована по {anchor_label} " - f"({anchor_used / 1_000_000:.1f} млн ₽)." + f" Оценка скорректирована по {anchor_label}" + f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)." ) sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"}) # Диапазон расширяем даже если медиану не двигали. diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py new file mode 100644 index 00000000..6a4ce8be --- /dev/null +++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py @@ -0,0 +1,226 @@ +"""#2677: домовой IMV-якорь приводится к базису ремонта оцениваемой квартиры. + +Дефект: `median_price` к моменту blend'а уже домножен на `_repair_coefficient` +(«требует ремонта» −6%, «евро» +10%), а домовой якорь из `house_imv_evaluations` +запрошен у Avito с ОДНИМ ремонтом (`renovation_type`). Порог `anchor > median×1.15` +и сам blend клали два разных базиса на одну шкалу — и, поскольку blend +однонаправленный (только вверх), у клиента с «требует ремонта» это возвращало +половину его −6% обратно наверх. + +Замер на проде (1061 персистированная оценка, 2026-08-10): blend не сработал ни +разу (0 маркеров в confidence_explanation), но из 240 оценок с домовым якорем порог +пересекали 19 — 9 из них «требует ремонта», и на них старый код давал медиану +на 3.3–9.8 % выше базис-согласованной (суммарно +4.13 млн ₽). +""" + +import os +from typing import Any + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +from app.services.estimator import _fetch_house_imv_anchor + +# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки +# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и +# упали на ЦИФРАХ (красный по существу), а не на collection error. + +# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽. +_PPM2 = 150_000.0 +_AREA = 40.0 +_BASE_MEDIAN = int(_PPM2 * _AREA) + + +def _make_listing(price_per_m2: float) -> dict[str, Any]: + return { + "id": int(price_per_m2), + "source": "avito", + "address": "ЕКБ, ул. Учителей, 18", + "price_rub": int(price_per_m2 * _AREA), + "price_per_m2": price_per_m2, + "area_m2": _AREA, + "rooms": 1, + "floor": 4, + "total_floors": 16, + "lat": 56.838, + "lon": 60.595, + "listing_date": None, + "days_on_market": None, + "source_url": f"https://example.test/{int(price_per_m2)}", + "photo_urls": [], + "distance_m": 10.0, + } + + +_ANALOGS = [ + _make_listing(135_000.0), + _make_listing(140_000.0), + _make_listing(145_000.0), + _make_listing(_PPM2), + _make_listing(155_000.0), + _make_listing(160_000.0), + _make_listing(165_000.0), +] + + +def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any: + """estimate_quality со всеми I/O застабленными; домовой якорь форсирован.""" + from app.schemas.trade_in import TradeInEstimateInput + from app.services.estimator import estimate_quality + from app.services.geocoder import GeocodeResult + + payload = TradeInEstimateInput( + address="ЕКБ, ул. Учителей, 18", + area_m2=_AREA, + rooms=1, + floor=4, + total_floors=16, + repair_state=repair_state, + ) + geo = GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", + provider="nominatim", + ) + + async def _run() -> Any: + with ( + patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(_ANALOGS), False, "S"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + patch( + "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(0.8, "per_rooms")), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor), + ): + return await estimate_quality(payload, MagicMock()) + + return anyio.run(_run) + + +def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]: + return { + "recommended_price": recommended, + "lower_price": int(recommended * 0.97), + "higher_price": int(recommended * 1.05), + "market_count": 500, + "rooms": 1, + "area_m2": _AREA, + "renovation_type": renovation, + } + + +# ── чистая функция ─────────────────────────────────────────────────────────── + + +def test_repair_factor_is_ratio_of_the_same_coefficients() -> None: + """Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое.""" + from app.services.estimator import _anchor_repair_factor + + # строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94) + assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94 + # строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00) + assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05 + # строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10) + assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94 + + +def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None: + """Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать». + + Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном + ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в + 1.0 при дрейфе вокабуляра тихо вернул бы дефект — поэтому базис, а не no-op. + """ + from app.services.estimator import _anchor_repair_factor + + assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94 + assert _anchor_repair_factor(None, "needs_repair") == 0.94 + # Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента. + assert _anchor_repair_factor(None, None) == 1.0 + assert _anchor_repair_factor("cosmetic", None) == 1.0 + assert _anchor_repair_factor(None, "standard") == 1.0 + + +# ── SQL-граница: без колонки правка молча выродится в no-op ────────────────── + + +def test_anchor_query_selects_renovation_type() -> None: + """`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0. + + Проверка строковая намеренно — это единственная точка, где видно, доедет ли + колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону + границы получает dict уже от вызывающего и такую регрессию не увидит. + """ + db = MagicMock() + db.execute.return_value.mappings.return_value.first.return_value = None + _fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA) + sql = str(db.execute.call_args[0][0]) + assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0] + + +# ── сквозь estimate_quality: цена, которую видит пользователь ──────────────── + + +def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None: + """«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт. + + median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000 + — якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000. + Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000 + < 6 486 000 → медиана остаётся 5 640 000 (−8.6 % к старому поведению). + """ + est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair") + assert est.median_price_rub == int(_BASE_MEDIAN * 0.94) + assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "") + + +def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None: + """Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен. + + median = 6 000 000 (coef 1.0). Старый порог 6 900 000 — якорь 7 000 000 его + перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro' + стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 → медиана не двигается. + """ + est = _run_estimate(_anchor(7_000_000, "euro"), None) + assert est.median_price_rub == _BASE_MEDIAN + + +def test_blend_still_fires_and_reports_the_rebased_anchor() -> None: + """Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится. + + median = 5 640 000, якорь 'cosmetic' 10 000 000 → в базисе клиента 9 400 000 + > 6 486 000 → blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении — + ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы + с карточкой, где показана сырая оценка Avito). + """ + est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair") + assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2 + explanation = est.confidence_explanation or "" + assert "9.4 млн ₽" in explanation + assert "приведённой к состоянию ремонта квартиры" in explanation + # Карточка Avito остаётся сырой — чужое число мы не правим. + assert est.avito_imv is not None + assert est.avito_imv.recommended_price == 10_000_000 From bcc4f2c29ee8cbd124c91cda201f0d852613ecc8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:29:28 +0000 Subject: [PATCH 07/60] =?UTF-8?q?docs(tradein/estimator):=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D1=80=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20IMV-=D1=8F=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=20=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D0=B8=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B4=D1=83=20=D0=BE=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B5=20(#2674)=20(#2852)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 89b9e84b..3aacd92d 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1184,11 +1184,32 @@ def _fetch_house_imv_anchor( """Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`. В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand - скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по - house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем - запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе - самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии - house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress). + скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку: + WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort: + None при любой ошибке / отсутствии house_id / пустой таблице — estimator + продолжает на гео-tier'ах (no regress). + + СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована + ~2951 домов, fresh» — неправда по обоим пунктам, #2674): + 2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) — + поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из + них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после + фикса параметров 2026-08-06, всего 47. + Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic' + и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по + объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217 + (46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла + «панель» вместо «монолита»). + + ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка): + · Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка + находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у + 224 из 224, то есть просто выключил бы механизм. + · Массовая переоценка — не чинит корень и упирается в очередь: 1628 из + 2633 домов уже в imv_status='ok', а батч берёт только pending/ + transient_error, где сейчас 7144 дома при темпе ~25/прогон. + · Корень — не свежесть, а разные базисы ремонта; починено в #2677 + (`_anchor_repair_factor`), см. ниже. Returns dict {recommended_price, lower_price, higher_price, market_count, rooms, area_m2, renovation_type} или None. From 3eabbd0186609ab7d06615c1a2131cb2476fef31 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:37:16 +0000 Subject: [PATCH 08/60] =?UTF-8?q?fix(tradein/quality):=20=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BD=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=91=D1=82=20=D1=80=D0=B0=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=C2=AB=D0=B4=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=81=20=D0=BA=D0=B0=D0=B4=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=C2=BB=20(#2674)=20(#2850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/259_data_quality_drop_pct_cadastr.sql | 145 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + .../tests/test_2674_dead_admin_metrics.py | 65 ++++++-- 3 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql diff --git a/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql new file mode 100644 index 00000000..16312e53 --- /dev/null +++ b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql @@ -0,0 +1,145 @@ +-- 259_data_quality_drop_pct_cadastr.sql +-- Purpose (#2674, третий показатель того же класса): убрать v_data_quality.pct_cadastr. +-- +-- 214 убрала outliers_flagged, 216 — price_disagreements_count по одному доводу: ноль, +-- гарантированный устройством системы, читается как «проверили — чисто», хотя честно он +-- означает «мы это не считаем». pct_cadastr — третий такой же, поэтому и действие то же: +-- не переключать источник, а снять показатель. +-- +-- ── ЧИСЛА С ПРОДА (2026-08-13, точный count) ──────────────────────────────── +-- v_data_quality.pct_cadastr .................... 0.000000000000000000000000 +-- знаменатель витрины (listings_active) ......... 45 198 (в listings всего 99 304) +-- listings.cadastral_number IS NOT NULL ......... 0 из 99 304 (и 0 из 45 198 активных) +-- deals.cadastral_number ........................ 0 из 96 974 +-- houses.cadastral_number (DaData) .............. 2 648 из 9 468 ← ДРУГОЙ объект +-- listings.building_cadastral_number ............ 30 970 из 99 304 ← ДРУГОЙ объект +-- +-- ── ЭТО НЕ ДЕФЕКТ ИЗМЕРИТЕЛЯ (контроль на здоровом образце в тех же данных) ── +-- Тот же CTE active_listings и тот же шаблон `count(*) WHERE IS NOT NULL * 100.0 +-- / NULLIF(count(*), 0)` в соседних строках витрины даёт 95.61% (pct_geocoded), 39.82% +-- (pct_description), 65.10% (pct_year_built). Ровно 0% — про колонку, а не про арифметику. +-- +-- ── ПОЧЕМУ НОЛЬ СТРУКТУРНЫЙ ───────────────────────────────────────────────── +-- listings.cadastral_number — кадастр КВАРТИРЫ. Единственное место в коде, которое его +-- вообще читает, — providers/cian/serp.py:886 (`offer.get("cadastralNumber")`); в парсерах +-- avito/yandex/domclick/n1 слов cadastr/kadastr нет ни разу, то есть для ЧЕТЫРЁХ площадок +-- из пяти ноль гарантирован НАШИМ кодом и о предметной области не говорит ничего. Пусто +-- при этом везде, где мы этот номер храним (три таблицы выше) — то же уже записано в +-- app/services/matching/houses.py: «площадки кадастр не отдают». +-- +-- ── ПОЧЕМУ НЕЛЬЗЯ «ПОЧИНИТЬ ОДНОЙ СТРОКОЙ», ПЕРЕКЛЮЧИВ НА СОСЕДНЮЮ КОЛОНКУ ── +-- Напрашивается считать по listings.building_cadastral_number (31.19% всего, 29.47% у +-- активных). Под подписью «доля объявлений с кадастром» это НОВАЯ ложь вместо старой: +-- * это кадастр ЗДАНИЯ, и в listings у него РОВНО ОДИН писатель — наш ночной KNN ≤50 м +-- по локальному зеркалу ЕГРН (tasks/cadastral_geo_match.py:161; проверено `git grep` +-- по origin/main: других INSERT/UPDATE этой колонки нет). Он не «тот же кадастр из +-- другого места», а наша производная; +-- * #2674 замерил ключ как неинъективный (656 из 3 260 значений накрывают >1 здание ГАР, +-- 20.1%; 751 из 2 864 зданий получают >1 значение, 26.2%) и прямо запретил считать его +-- идентичностью здания; +-- * разброс по площадкам среди активных геокодированных (cian 33.8%, yandex 20.3%, +-- avito 42.0%, domclick 49.7%) — про точность НАШИХ координат и охват зеркала по ЕКБ, +-- а не про качество объявления. +-- Переименовать подпись мало: честное имя было бы «доля объявлений, которым ночной KNN +-- подобрал здание в 50 м» — это другой показатель, и заводить его надо отдельно и +-- осознанно, а не под видом починки этого. Авторитетный кадастр здания у нас есть — +-- houses.cadastral_number из DaData (2 648/9 468 домов), но он про ДОМА, а витрина считает +-- ОБЪЯВЛЕНИЯ; подставить его в эту строку — снова назвать одно другим. +-- +-- ── ЦЕНА ПРАВКИ ──────────────────────────────────────────────────────────── +-- Читателей у витрины в коде нет (grep по /app/app в живом backend-контейнере пуст; +-- /api/v1/admin/scraper/data-quality считает свои метрики сам и кадастр не показывает +-- вовсе) — это ручной psql-снимок. Зависимых объектов у view тоже нет (pg_depend по +-- 'v_data_quality'::regclass, прод 13.08: 0 строк), поэтому CASCADE не нужен и не должен +-- появиться: в этом продукте `DROP ... CASCADE` уже терял гранты FDW-пользователю (C3). +-- +-- ── ПОРЯДОК И БЛОКИРОВКА ─────────────────────────────────────────────────── +-- CREATE OR REPLACE VIEW колонку УДАЛИТЬ не может → DROP VIEW → CREATE VIEW (тот же +-- порядок, что 214/216). DROP VIEW берёт ACCESS EXCLUSIVE, поэтому `SET LOCAL +-- lock_timeout` (см. scripts/check-migration-lock-timeout.py). В отличие от 222, которая +-- обошлась CREATE OR REPLACE, здесь COMMENT ON VIEW надо выставить ЗАНОВО: DROP уносит +-- комментарий вместе с объектом. +-- +-- Тело SELECT скопировано из 222_db_audit_cleanup.sql (последний DDL; сверено с живым +-- pg_get_viewdef на проде 13.08 — совпадает) минус строка pct_cadastr. Из CTE убран +-- ставший ненужным cadastral_number: 222 завела явный список колонок ровно затем, чтобы +-- view не держал column-level зависимость на то, чего не показывает. +-- +-- Dependencies: 216_dead_code_sweep.sql (текст COMMENT ON VIEW), 222_db_audit_cleanup.sql +-- (последний DDL v_data_quality). +-- Apply after: 258_houses_imv_transient_attempts.sql +-- Идемпотентно: DROP VIEW IF EXISTS + CREATE VIEW + COMMENT — повторный прогон даёт тот +-- же результат. + +BEGIN; + +-- Ждём лок не дольше 5 s: сам DROP мгновенный, но ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами (#2791/#2792). +SET LOCAL lock_timeout = '5s'; + +DROP VIEW IF EXISTS v_data_quality; + +-- DDL идентичен 222, минус строка pct_cadastr и минус cadastral_number в CTE. +CREATE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT id, lat, description, house_id_fk, is_active + FROM listings + WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago; + +-- Текст 216 + абзац про pct_cadastr. Выставляем заново, потому что DROP VIEW выше унёс +-- прежний комментарий вместе с объектом. +COMMENT ON VIEW v_data_quality IS + 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): ' + '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. ' + '#2674: price_disagreements_count убран — у всех 89 699 объявлений ровно один ' + 'источник, поэтому показатель структурно не мог быть ненулевым и ноль читался как ' + '«расхождений нет» вместо «мы не сравниваем». listings_dedup_2sources оставлен ' + 'намеренно: он ту же пустоту называет своим именем («объявлений с 2+ источниками»), ' + 'ноль в нём — честный ответ, а не мнимое благополучие. ' + '#2674 (мигр. 259): pct_cadastr убран по тому же доводу — считал ' + 'listings.cadastral_number (кадастр КВАРТИРЫ), а его не отдаёт ни одна площадка: ' + '0 из 99 304 объявлений, 0 из 96 974 deals, единственный читающий его парсер — ' + 'cian/serp.py. Показатель НЕ переведён на listings.building_cadastral_number: та ' + 'колонка — кадастр ЗДАНИЯ и на 100% производная нашего ночного KNN ≤50 м ' + '(tasks/cadastral_geo_match.py), неинъективного как ключ здания (#2674: 20.1% ' + 'значений накрывают >1 здание ГАР); под подписью «доля объявлений с кадастром» она ' + 'мерила бы покрытие нашего геокодера, а не качество объявлений.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 119f1fb0..61f36e02 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -247,3 +247,4 @@ 254_listings_backfill_avito_rating_glued_address.sql 257_listings_backfill_yandex_source_url.sql 258_houses_imv_transient_attempts.sql +259_data_quality_drop_pct_cadastr.sql diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py index ced39ac9..37d2df85 100644 --- a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py +++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py @@ -275,25 +275,62 @@ def test_migration_drops_every_dead_column() -> None: assert "DROP COLUMN IF EXISTS is_outlier" in sql +_VIEW_MARKER = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b") + + +def _latest_v_data_quality() -> tuple[str, str]: + """(текст последней миграции, создающей v_data_quality; тело её SELECT). + + Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой + DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять старую + миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический: + деплой применяет файлы отсортированными, последний по имени — последний в проде. + """ + creators = sorted( + p for p in _SQL_DIR.glob("*.sql") if _VIEW_MARKER.search(p.read_text("utf-8")) + ) + assert creators, "не найдено ни одной миграции, создающей v_data_quality" + sql = creators[-1].read_text(encoding="utf-8") + hit = _VIEW_MARKER.search(sql) + assert hit is not None + return sql, sql[hit.end() :].split(";")[0] + + def test_latest_v_data_quality_no_longer_reports_outliers() -> None: - """Действующее определение v_data_quality (последняя миграция, которая его - создаёт) не упоминает is_outlier. + """Действующее определение v_data_quality не упоминает is_outlier. Red на origin/main: там последним был 095_dead_schema.sql со строкой `(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged` — показатель, который не мог быть ненулевым, потому что колонку не писал никто. - - Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой - DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту - миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический: - деплой применяет файлы отсортированными, последний по имени — последний в проде. """ - marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b") - creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8"))) - assert creators, "не найдено ни одной миграции, создающей v_data_quality" - latest = creators[-1].read_text(encoding="utf-8") - hit = marker.search(latest) - assert hit is not None - body = latest[hit.end() :].split(";")[0] + _, body = _latest_v_data_quality() assert "outliers_flagged" not in body assert "is_outlier" not in body + + +def test_latest_v_data_quality_no_longer_reports_flat_cadastre() -> None: + """Тот же класс, третий случай: pct_cadastr (мигр. 259). + + Считался по listings.cadastral_number — кадастру КВАРТИРЫ, которого не отдаёт ни + одна площадка (прод 13.08: 0 из 99 304 объявлений, 0 из 96 974 deals), поэтому + показатель не мог быть ненулевым, а «0.000000» рядом с pct_geocoded 95.61% + читался как измеренное качество данных. + + Red на origin/main: последний DDL там — 222_db_audit_cleanup.sql, в нём строка + `... WHERE cadastral_number IS NOT NULL ... AS pct_cadastr` на месте. + + Замена источника на listings.building_cadastral_number — НЕ починка: та колонка + про ЗДАНИЕ и целиком производная нашего ночного KNN ≤50 м, который #2674 + замерил как неинъективный ключ здания. Поэтому тест запрещает и её появление + в этой витрине. + """ + sql, body = _latest_v_data_quality() + assert "pct_cadastr" not in body, "показатель вернулся в v_data_quality" + assert "cadastral_number" not in body, ( + "в витрину подставили другой кадастр — под подписью «доля объявлений с " + "кадастром» это новая ложь вместо старой (см. шапку 259)" + ) + # DROP VIEW уносит COMMENT вместе с объектом — миграция, которая дропает, обязана + # выставить его заново, иначе объяснение «почему показателя нет» молча теряется. + if re.search(r"DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?v_data_quality\b", sql): + assert "COMMENT ON VIEW v_data_quality" in sql From 17d558b18cfffbbabd8683db9658b8766aedc23f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:44:11 +0000 Subject: [PATCH 09/60] =?UTF-8?q?fix(tradein/yandex):=20=D1=81=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B7=D0=BD=D0=B0=D0=BA?= =?UTF-8?q?=20=C2=AB=D0=BF=D0=B0=D0=BD=D0=BE=D1=80=D0=B0=D0=BC=D0=B0=C2=BB?= =?UTF-8?q?=20=E2=80=94=20=D0=B5=D0=B3=D0=BE=20=D0=BD=D0=B5=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D0=B5=20(#2?= =?UTF-8?q?851)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 77 ++------ .../services/matching/conflict_resolution.py | 6 +- .../data/sql/260_houses_drop_has_panorama.sql | 179 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + .../matching/test_conflict_resolution.py | 15 +- .../tests/test_dead_code_sweep_2674.py | 158 ++++------------ .../test_estimator_yandex_integration.py | 24 +-- .../tests/test_yandex_history_area_filter.py | 11 +- .../backend/tests/test_yandex_valuation.py | 17 +- .../tests/test_yandex_valuation_save.py | 4 +- .../scraper_kit/providers/yandex/valuation.py | 7 +- 11 files changed, 280 insertions(+), 219 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 3aacd92d..893da182 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import ( estimate_via_cian_valuation, ) from scraper_kit.providers.yandex.valuation import ( - ValuationHouseMeta, YandexValuationResult, YandexValuationScraper, ) @@ -987,13 +986,13 @@ def _save_yandex_history_items( Batch semantics: single try/except; on any failure the batch rolls back. - #2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по - пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но - без единого объявления в истории, до записи панорамы не доходила — на проде это - 1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена - переноса: match_or_create_house теперь вызывается и для таких страниц (может - СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на - остальных 90% — новых сущностей класс не появляется, появляется недостающая доля. + #2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше + возврат стоял первым, и страница, отрисованная идеально, но без единого объявления + в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с + историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака + «панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам + match_or_create_house, то есть дом и строка house_sources для таких страниц. Это + тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%. """ # Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16] # — stable across re-runs, distinguishes pages for different addresses. @@ -1030,9 +1029,6 @@ def _save_yandex_history_items( result.address, ) - # Наблюдение о доме не зависит от того, есть ли на странице история объявлений. - _save_yandex_house_panorama(db, house_id, result.house) - if not result.history_items: return 0 @@ -1117,58 +1113,13 @@ def _save_yandex_history_items( return 0 -# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в -# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но -# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь — -# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету. -# -# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text -# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн, -# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет». -# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность -# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL. -def _save_yandex_house_panorama( - db: Session, - house_id: int | None, - meta: ValuationHouseMeta, -) -> None: - """Пишет houses.has_panorama по разобранной мете yandex_valuation. - - No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт - выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за - справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает - только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история. - """ - if house_id is None: - return - if meta.year_built is None and meta.total_floors is None: - logger.debug( - "yandex_valuation: has_panorama не пишем для house_id=%s — " - "страница не подтверждена (нет ни года, ни этажности)", - house_id, - ) - return - try: - with db.begin_nested(): - db.execute( - text( - """ - UPDATE houses - SET has_panorama = CAST(:panorama AS boolean) - WHERE id = CAST(:hid AS bigint) - AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean) - """ - ), - {"hid": house_id, "panorama": meta.has_panorama}, - ) - db.commit() - except Exception as e: - logger.warning( - "yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s", - house_id, - e, - ) - db.rollback() +# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses +# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог +# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за +# 24.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200, +# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах, +# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова +# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего. # ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py index 89d71531..2e56f1eb 100644 --- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py +++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py @@ -71,7 +71,11 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = { "commission_year": ["cian_serp", "yandex_realty_nb"], "commission_month": ["yandex_realty_nb"], # raw RU month name "developer_name": ["cian", "yandex_realty_nb"], - "has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag + # #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259). + # В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала, + # источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда, + # потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на + # проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML). "yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории # Yandex Valuation enrichment (existing house attrs) "has_lift": ["cian_bti", "cian_detail", "yandex_valuation"], diff --git a/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql new file mode 100644 index 00000000..58fcaecc --- /dev/null +++ b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql @@ -0,0 +1,179 @@ +-- 259_houses_drop_has_panorama.sql +-- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области. +-- +-- Dependencies: 031_houses_alter_yandex.sql (завела колонку), +-- 154_market_contract_views.sql (внесла её в публичный контракт +-- market.v_houses), 155_reader_grants_to_contract_views.sql (грант +-- gendesign_reader на этот view). +-- Apply after: 258_houses_imv_transient_attempts.sql +-- Deploy order: код УЖЕ впереди схемы — писатель (_save_yandex_house_panorama), +-- парсер (ValuationHouseMeta.has_panorama) и правило разрешения конфликтов +-- (HOUSE_FIELD_PRIORITY) сняты тем же PR, что несёт этот файл. Обратный порядок +-- (снести колонку, оставить писателя) давал бы падающий UPDATE на каждой оценке +-- yandex_valuation — молча проглоченный, но с WARNING в логах. +-- +-- ── ЧТО ЗА НОЛЬ И ПОЧЕМУ ЭТО НЕ ДЕФЕКТ ────────────────────────────────────── +-- Колонка заполнялась `"Панорама" in body_text` по тексту страницы оценки Яндекса. +-- external_valuations (source='yandex_valuation', raw_payload->'house'), 24.05–12.08.2026: +-- страниц ............................................................. 1536 +-- has_panorama = true .................................................... 0 +-- has_panorama = false ................................................ 1536 +-- ключ отсутствует ....................................................... 0 +-- houses: 9468 строк, has_panorama непустых 12, из них true 0. +-- +-- Это НЕ «метка переехала» и НЕ «путь записи оборван». Живая проверка боевым трактом +-- 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig — тот же +-- клиент, что у estimator.py; только чтение) взяла три адреса Екатеринбурга, все HTTP 200: +-- Советская 51 ...... HTML 1 191 929 б — мета разобралась: 1974 г., 9 эт., панель, +-- 2,50 м потолки, 46 объектов +-- Парина 46/5 ....... HTML 1 185 458 б — 2020 г., 18 эт. +-- Сурикова 47 ....... 1977 г., 5 эт., кирпич, 184 объекта +-- Вхождений «анорам» (без учёта регистра) в ПОЛНОМ HTML: 0, 0, 0. Равно как panorama / +-- 3D-тур / Виртуальн / Street — 0. Переехать в атрибут, data-*, JSON-стейт или иную +-- вёрстку метка не могла: её нет в документе целиком. Словарь удобств дома на странице: +-- «Дом 1974 года · 9 этажей · Панельное здание · 2,50 м потолки · Газ · Лифт · +-- Мусоропровод», причём с ЯВНЫМИ отрицаниями («Лифт отсутствует», «Мусоропровода нет») — +-- будь панорама признаком дома, она печаталась бы в этом ряду и в отрицательной форме. +-- +-- Ноль был механически гарантирован самим кодом и о предметной области не говорил +-- ничего, кроме одного: измерять нечего. Третий вид нуля — НЕПРИМЕНИМО, лечится +-- удалением, а не починкой разбора. +-- +-- ОГОВОРКА ЧЕСТНОСТИ: сырой HTML прошлых сборов не хранится (raw_payload держит только +-- body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не +-- опровергается. Но и положительных за всё окно 1536 страниц ноль — в измеренной +-- истории её тоже не было. +-- +-- ── ГЛАВНАЯ ЦЕНА: ЛОМАЕМ ПУБЛИЧНЫЙ КОНТРАКТ ──────────────────────────────── +-- has_panorama входит в market.v_houses (154), где сказано прямым текстом: «adding a +-- column later is backward compatible, renaming/removing one is not». Это осознанное +-- ломающее изменение контракта, а не недосмотр. Основание — консьюмер колонку не +-- читает: `git grep has_panorama` вне tradein-mvp пуст (в т.ч. +-- backend/app/services/etl/newbuilding_crossload.py, единственный живой читатель +-- контракта, #976/#2130). Держать в публичном обещании поле, которое всегда false и +-- никогда не станет ничем другим, — обещать данные, которых не существует. +-- +-- CREATE OR REPLACE VIEW удалить колонку не умеет, поэтому view пересоздаётся: +-- DROP VIEW → DROP COLUMN → CREATE VIEW. Порядок обязателен ещё и потому, что +-- DROP COLUMN без CASCADE упрётся в зависимость view (проверено на проде: единственный +-- зависимый объект — market.v_houses). CASCADE НЕ используем — он снёс бы и то, что +-- появится позже, без единого слова в логе. +-- +-- ГРАНТЫ ТЕРЯЮТСЯ ПРИ DROP VIEW (это уже кусало: C3, FDW-гранты после DROP ... CASCADE). +-- На проде на market.v_houses висит GRANT SELECT для gendesign_reader (155) — он +-- восстанавливается ниже явно, тем же стейтментом, что и в 155. Без этой строки +-- внешний ETL получил бы permission denied на следующем же прогоне. +-- +-- ── СТОИМОСТЬ БЛОКИРОВКИ И SET LOCAL lock_timeout ────────────────────────── +-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на houses. Удержание дёшево и не +-- зависит от размера: PostgreSQL не переписывает heap, а помечает attisdropped в +-- каталоге — единицы миллисекунд на 9468 строк. Дорого ОЖИДАНИЕ выдачи лока: ждущий +-- ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, и за ним начинают ждать +-- обычные SELECT приложения — ровно то, что 2026-08-07 положило деплой на 29 минут +-- (#2791, #2792). Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено +-- deadlock_timeout = 1 s на проде; на работу ПОД локом не влияет). Срабатывание = +-- честный красный деплой через 5 секунд, миграция не помечается применённой. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP VIEW IF EXISTS + DROP COLUMN IF EXISTS + CREATE VIEW после DROP — +-- безопасный re-run. +-- - Без CASCADE. +-- - Откат: колонку вернуть можно (ALTER TABLE houses ADD COLUMN has_panorama boolean), +-- данные не восстановятся — восстанавливать нечего, все 12 непустых значений false. +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. information_schema.columns по houses: has_panorama отсутствует. +-- 3. market.v_houses существует, has_panorama в нём нет, остальные 59 колонок на +-- месте и в том же порядке (прод до правки: 60), SELECT count(*) отдаёт 9468+ строк. +-- 4. information_schema.role_table_grants: gendesign_reader снова имеет SELECT на +-- market.v_houses. + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP VIEW IF EXISTS market.v_houses; + +ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama; + +-- Пересоздание контракта БЕЗ has_panorama. Список колонок — копия 154 минус одна +-- строка; он и есть обещание стабильности, поэтому выписан явно, без SELECT *. +CREATE VIEW market.v_houses AS +SELECT + id, + source, + ext_house_id, + url, + slug, + address, + full_address, + short_address, + lat, + lon, + geom, + year_built, + house_type, + house_class, + material_walls, + material_floors, + series_name, + total_floors, + total_units, + entrances, + flat_count, + is_emergency, + passenger_elevators, + cargo_elevators, + has_concierge, + closed_yard, + has_playground, + hot_water, + heat_supply_type, + gas_supply_type, + overlap_type, + parking_type, + infrastructure_summary, + infrastructure_walk_distance, + developer_name, + developer_key, + management_company_id, + rating, + reviews_count, + rating_score, + rating_string, + transport_accessibility_rate, + advantages, + banks, + builders, + houses_by_turn, + corpus_count, + commission_year, + commission_month, + total_area_ha, + cadastral_number, + house_fias_id, + yandex_jk_id, + yandex_jk_slug, + cian_internal_house_id, + cian_zhk_url, + raw_payload, + first_seen_at, + last_scraped_at +FROM public.houses; + +COMMENT ON VIEW market.v_houses IS + 'Stable public contract over public.houses (#2130). Explicit column list is the ' + 'stability promise — do not SELECT * against the base table from external ' + 'consumers. raw_payload is included because it is read today by gendesign ETL ' + '#976 (newbuilding_crossload.py); scraper-internal QC/status/validated_at ' + 'bookkeeping columns are intentionally excluded. #2674 (хвост): has_panorama ' + 'убрана из контракта вместе с колонкой — ломающее изменение, принятое осознанно ' + '(0 true из 1536 страниц, признака нет на площадке, читателей вне tradein нет).'; + +-- DROP VIEW уничтожил гранты — восстанавливаем ровно то, что дала 155. +GRANT SELECT ON market.v_houses TO gendesign_reader; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 61f36e02..80ff61ee 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -248,3 +248,4 @@ 257_listings_backfill_yandex_source_url.sql 258_houses_imv_transient_attempts.sql 259_data_quality_drop_pct_cadastr.sql +260_houses_drop_has_panorama.sql diff --git a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py index 8e5e1163..4495830f 100644 --- a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py +++ b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py @@ -113,9 +113,17 @@ class TestYandexHousePriority: assert "ceiling_height" not in HOUSE_FIELD_PRIORITY - def test_house_has_panorama_yandex_valuation_only(self) -> None: - out = resolve_house_field("has_panorama", {"yandex_valuation": True}) - assert out is True + def test_has_panorama_removed_from_house_priority(self) -> None: + """#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259). + + В отличие от ceiling_height выше, это правило было ИСПОЛНИМО — колонка + существовала, единственный источник её писал. Разрешать было нечего: + yandex_valuation отдавал False всегда (0 true из 1536 страниц на проде), + потому что слова «панорам» на странице оценки нет вовсе. + """ + from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY + + assert "has_panorama" not in HOUSE_FIELD_PRIORITY def test_house_yandex_total_listings_yandex_valuation_only(self) -> None: out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42}) @@ -217,7 +225,6 @@ class TestYandexListingPriority: "corpus_count", "total_area_ha", "commission_month", - "has_panorama", "yandex_total_listings", ] for key in yandex_keys: diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py index bdb6f976..5e6e0129 100644 --- a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -3,8 +3,6 @@ Каждая правка эпика — тест, который краснеет без неё: подключено: - - houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница - не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»); - domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules — именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным; - filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash. @@ -15,7 +13,11 @@ читателя и писателя; - listings.merged_into, house_sources.raw_payload — колонки без писателя; - v_data_quality.price_disagreements_count — показатель, который не мог быть - ненулевым. + ненулевым; + - houses.has_panorama (хвост, мигр. 259) — признак, которого нет на площадке. + Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя; + подключённый писатель за 7 дней записал 12 значений, все false. Разница между + «проводка оборвана» и «мерить нечего» видна не по коду, а по площадке. задокументировано: - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — @@ -29,19 +31,13 @@ from __future__ import annotations import os import re from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from scraper_kit.providers.cian.valuation import _parse_valuation_state -from scraper_kit.providers.yandex.valuation import ( - ValuationHistoryItem, - ValuationHouseMeta, - YandexValuationResult, -) from app.services import estimator -from app.services.estimator import _save_yandex_history_items REPO_ROOT = Path(__file__).resolve().parents[3] TRADEIN = REPO_ROOT / "tradein-mvp" @@ -50,118 +46,7 @@ MIGRATION = SQL_DIR / "216_dead_code_sweep.sql" # ───────────────────────────────────────────────────────────────────────────── -# Подключено 1/3: houses.has_panorama -# ───────────────────────────────────────────────────────────────────────────── - - -def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult: - return YandexValuationResult( - address="Екатеринбург, ул. Куйбышева, 106", - offer_category="APARTMENT", - offer_type="SELL", - page=1, - source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test", - house=meta, - history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)], - ) - - -def _panorama_updates(db: MagicMock) -> list[dict]: - """Параметры всех db.execute, которые обновляли houses.has_panorama.""" - found = [] - for call in db.execute.call_args_list: - sql = str(call.args[0]) - if "has_panorama" in sql and "UPDATE houses" in sql: - found.append(call.args[1]) - return found - - -def test_has_panorama_written_when_page_rendered() -> None: - """Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой.""" - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True) - ) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(99, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - updates = _panorama_updates(db) - assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674" - assert updates[0] == {"hid": 99, "panorama": True} - - -def test_has_panorama_false_written_when_page_rendered() -> None: - """Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false.""" - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False) - ) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(7, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [{"hid": 7, "panorama": False}] - - -def test_has_panorama_written_when_page_has_no_history() -> None: - """Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде. - - Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие - страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение - о доме к наличию объявлений отношения не имеет. - """ - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True) - ) - result.history_items = [] - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(42, 0.9, "fp"), - ): - assert _save_yandex_history_items(db, result) == 0 - - assert _panorama_updates(db) == [{"hid": 42, "panorama": True}] - - -def test_has_panorama_not_written_when_page_unconfirmed() -> None: - """Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false.""" - db = MagicMock() - result = _result_with_meta(ValuationHouseMeta(has_panorama=False)) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(5, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали" - - -def test_has_panorama_not_written_without_house_id() -> None: - """Дом не сматчился → писать некуда, но и падать нельзя.""" - db = MagicMock() - result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16)) - - with patch( - "app.services.estimator.match_or_create_house", - side_effect=RuntimeError("no house"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка +# Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка # ───────────────────────────────────────────────────────────────────────────── @@ -217,7 +102,7 @@ def test_domrf_handler_reuses_loader_functions() -> None: # ───────────────────────────────────────────────────────────────────────────── -# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали +# Подключено 2/2: filters_hash лежит на уровень выше, чем его читали # ───────────────────────────────────────────────────────────────────────────── @@ -285,6 +170,10 @@ def test_dead_names_absent_from_live_code() -> None: "asking_to_sold_ratios_tiered", "asking_to_sold_tier_bounds", "price_disagreements_count", + # Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета + # источников. Гейт краснеет на любом из трёх, а не только на одном (первая + # редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет). + "has_panorama", ] offenders: list[str] = [] for path in _live_python_sources(): @@ -332,6 +221,29 @@ def test_migration_drops_exactly_what_was_declared_dead() -> None: assert "COMMENT ON VIEW v_price_divergence" in sql +def test_has_panorama_dropped_together_with_the_contract_column() -> None: + """Снос колонки обязан пересобрать market.v_houses и вернуть ему грант. + + has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку + не удаляет — значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три + части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен. + """ + sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8") + assert "DROP VIEW IF EXISTS market.v_houses" in sql + assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql + assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока" + + view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0] + assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой" + # Контракт — обещание стабильности: пересборка не должна заодно потерять соседей. + for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"): + assert kept in view_ddl, f"{kept} пропала из контракта при пересборке" + assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, ( + "DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит " + "permission denied на следующем прогоне" + ) + + def test_price_divergence_is_documented_as_structurally_empty() -> None: """Оставленный задел обязан говорить, чем он НЕ является сегодня.""" sql = MIGRATION.read_text(encoding="utf-8") diff --git a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py index b5d6ebbe..5962af60 100644 --- a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py +++ b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py @@ -178,21 +178,22 @@ def test_save_history_items_inserts_each(): ): saved = _save_yandex_history_items(db, result) assert saved == 2 - # 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE - # houses.has_panorama — считаем именно вставки истории, а не все execute. + # 1 batch INSERT (executemany). Фильтруем по SQL, а не по позиции вызова: + # #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой. rows = _history_rows(db) assert isinstance(rows, list) and len(rows) == 2 - # Два коммита: панорама (до истории) + батч истории. Раньше был один. - assert db.commit.call_count == 2 + # Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе + # с колонкой, хвост #2674, мигр. 259. + assert db.commit.call_count == 1 def test_save_history_items_empty_no_commit(): """Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего. #2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно - отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%). - Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается — - а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает. + отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь + match_or_create_house вызывается до возврата — а записей по-прежнему ноль: + истории нет, вставлять нечего. """ db = MagicMock() result = YandexValuationResult( @@ -246,9 +247,9 @@ def test_save_history_items_ext_id_stable_across_calls(): def test_save_history_items_db_error_rolls_back_batch(): """Any item failing rolls back the whole batch — batch semantics (finding #5). - #2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение - доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает), - а батч истории проходил бы успешно, и тест молча проверял бы не тот путь. + #2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе + и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы + не тот путь, стоит появиться любому новому execute перед вставкой истории. """ db = MagicMock() @@ -266,5 +267,4 @@ def test_save_history_items_db_error_rolls_back_batch(): saved = _save_yandex_history_items(db, result) assert saved == 0 # whole batch rolled back db.rollback.assert_called_once() - # Панорама коммитится отдельно и раньше — её успех не отменяет отката истории. - assert db.commit.call_count == 1 + assert db.commit.call_count == 0 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 44d6ac26..a21a9509 100644 --- a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py +++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py @@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]: """Строки батча house_placement_history из мока сессии. #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция - сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama - перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + сломалась, как только у функции появился второй execute перед вставкой истории. + Фильтруем по SQL: тест переживёт любой новый вызов. """ for call in db.execute.call_args_list: if "INSERT INTO house_placement_history" in str(call.args[0]): @@ -169,10 +169,9 @@ def test_all_invalid_area_returns_zero_no_crash() -> None: assert saved == 0 # db.execute не должен вызываться для пустого rows (нет INSERT) assert _history_rows(db) == [] - # Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись - # houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась - # ли история по площади. - assert db.commit.call_count == 2 + # Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит + # (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259. + assert db.commit.call_count == 1 db.rollback.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py index 21d6f605..c6196e84 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -1,7 +1,7 @@ """Unit tests for YandexValuationScraper — anonymous house-history scraper. Fixture HTML simulates the Yandex valuation page body text containing: -- House meta block (year, floors, type, ceiling, lift, total objects, panorama) +- House meta block (year, floors, type, ceiling, lift, total objects) - 2-3 historical offer entries with full structure Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг @@ -78,7 +78,6 @@ def test_parse_house_meta_full(): assert meta.ceiling_height == 2.50 assert meta.has_lift is True assert meta.total_objects == 12 - assert meta.has_panorama is False def test_parse_house_meta_no_lift(): @@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift(): assert meta.ceiling_height == 3.0 -def test_parse_house_meta_with_panorama(): - text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание" +def test_parse_house_meta_brick_with_lift(): + """#2674 (хвост): фикстура больше не содержит «Панорама». + + Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором; + зелёный он был по построению. На настоящей странице оценки этого слова нет — + проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов. + Остаток теста осмысленный: кирпич + лифт на короткой мета-строке. + """ + text = "7 объектов Дом 2010 года Лифт Кирпичное здание" meta = YandexValuationScraper._parse_house_meta(text) - assert meta.has_panorama is True assert meta.has_lift is True assert meta.house_type == "brick" @@ -275,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items(): Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item. """ text = ( - "Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт " + "Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт " "1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже " "2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже" ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py index 308d4cb1..4b90d5ef 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation_save.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation_save.py @@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]: """Строки батча house_placement_history из мока сессии. #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция - сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama - перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + сломалась, как только у функции появился второй execute перед вставкой истории. + Фильтруем по SQL: тест переживёт любой новый вызов. """ for call in db.execute.call_args_list: if "INSERT INTO house_placement_history" in str(call.args[0]): diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py index 0c8c883c..589be38e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py @@ -62,7 +62,11 @@ class ValuationHouseMeta(BaseModel): ceiling_height: float | None = None # in meters, e.g. 2.5 has_lift: bool | None = None total_objects: int | None = None # 'N объектов' (full archive count) - has_panorama: bool = False # 'Панорама' label present + # #2674 (хвост, мигр. 259): признак «панорама» снят с разбора. Слова «панорам» в + # любом падеже на странице оценки нет вовсе — проверено боевым трактом 13.08.2026 + # на трёх адресах ЕКБ (полный HTML ~1.19 МБ, вхождений 0/0/0), при том что мета + # разбиралась целиком (год/этажность/материал/потолки/объекты). Признак не + # «переехал» — его в предметной области нет; на проде 0 true из 1536 страниц. def validate_match( self, @@ -331,7 +335,6 @@ class YandexValuationScraper(BaseScraper): ceiling_height=(float(ceiling_m.group(1).replace(",", ".")) if ceiling_m else None), has_lift="Лифт" in body_text, total_objects=int(objects_m.group(1)) if objects_m else None, - has_panorama="Панорама" in body_text, ) def _parse_history_items(self, tree: HTMLParser, body_text: str) -> list[ValuationHistoryItem]: From 4b527751b9357edce285b412320b1cc1f849ac93 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:55:05 +0000 Subject: [PATCH 10/60] =?UTF-8?q?fix(tradein/domclick):=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?QRATOR-=D0=B1=D0=BB=D0=BE=D0=BA=20=D1=81=D0=B2=D0=B8=D0=BF?= =?UTF-8?q?=D0=B0=20=E2=80=94=20ban=5Fkind=3D'platform',=20=D0=BD=D0=B5=20?= =?UTF-8?q?'unknown'=20(#2687)=20(#2832)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_2687_domclick_ban_kind.py | 86 +++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 7 ++ 2 files changed, 93 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py diff --git a/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py b/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py new file mode 100644 index 00000000..cef84cae --- /dev/null +++ b/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py @@ -0,0 +1,86 @@ +"""#2687: распознанный QRATOR-блок домкликового свипа — это 'platform', не 'unknown'. + +Ветка `if counters.blocked:` в `run_domclick_city_sweep` входится ТОЛЬКО когда +скрейпер поднял `DomClickBlockedError` (площадка отдала QRATOR block-страницу). Это +ровно определение `BAN_KIND_PLATFORM`. До правки свип звал `mark_banned` без +`ban_kind`, и все его баны падали в дефолт 'unknown' (#2764) — диагноз был +УСТАНОВЛЕН (блок распознан), но не передавался, и мониторинг не мог отличить +«площадка отбила» (platform) от «тракт/прокси» (infra). + +Прод 2026-08-12: domclick_city_sweep 3751/3675/3594 все `banned` с `blocked=1`, +`ban_kind='unknown'`. На старом коде тест ниже падает (ban_kind='unknown'). +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.pipeline import run_domclick_city_sweep + +PFX = "scraper_kit.orchestration.pipeline" + + +class _BanKindRecorder: + """Ловит ban_kind, переданный в mark_banned (остальное — no-op).""" + + def __init__(self) -> None: + self.ban_kind: str | None = None + self.marked: list[str] = [] + + def is_cancelled(self, db: Any, run_id: int) -> bool: + return False + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + return None + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.marked.append("done") + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.marked.append("failed") + + def mark_banned( + self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any + ) -> None: + self.marked.append("banned") + self.ban_kind = kw.get("ban_kind") + + +async def _drive_blocked() -> _BanKindRecorder: + recorder = _BanKindRecorder() + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)]) + scraper.blocked = True # распознанный QRATOR-блок + scraper.geo_filtered = 0 + scraper.fetch_errors = 1 + scraper.buckets_completed = 2 + scraper.buckets_total = 6 + with ( + patch(f"{PFX}.DomClickScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(39, 0))), + patch(f"{PFX}.runs", recorder), + ): + await run_domclick_city_sweep( + MagicMock(), + config=SimpleNamespace(browser_http_endpoint="http://x:9000"), + matcher=MagicMock(), + run_id=1, + city_id=4, + pages=1, + request_delay_sec=0.0, + ) + return recorder + + +async def test_qrator_block_marks_platform_ban_kind() -> None: + """Блок распознан → ban_kind='platform'. На старом коде было 'unknown' (дефолт).""" + recorder = await _drive_blocked() + assert recorder.marked[-1] == "banned" + assert recorder.ban_kind == "platform" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 1f3bdd1a..b3d462d5 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -4000,6 +4000,13 @@ async def run_domclick_city_sweep( f"QRATOR block aborted sweep — {counters.lots_fetched} listings " "collected before abort (#2657)", counters.to_dict(), + # #2687: эта ветка входится ТОЛЬКО при распознанном QRATOR-блоке + # (counters.blocked == 1, DomClickBlockedError — площадка показала + # block-страницу). Это ровно определение BAN_KIND_PLATFORM + # («площадка нас заблокировала»), а не 'unknown'. Раньше домкликовый + # свип не передавал ban_kind и все его баны падали в 'unknown', хотя + # диагноз здесь установлен, а не «не установлен» (#2764). + ban_kind=BAN_KIND_PLATFORM, ) elif 0 < counters.buckets_completed < counters.buckets_total: # #2670: прогон оборван на середине — прошёл часть комнатных бакетов и From 06c322c1677703e1b412d673caa70388832bd19a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 06:02:38 +0000 Subject: [PATCH 11/60] =?UTF-8?q?fix(tradein/proxy):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BF=D1=80=D0=B0=D1=88=D0=B8=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=89=D0=B8=D1=89=D1=91=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=BF=D1=83=D1=82=D1=8C,=20=D0=B0=20=D0=BD=D0=B5=20robots.t?= =?UTF-8?q?xt=20(#2855)=20(#2856)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_2800_per_source_probe.py | 105 ++++++++++++++++-- .../src/scraper_kit/browser_fetcher.py | 32 +++++- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py index 37fc10fe..7fa59d45 100644 --- a/tradein-mvp/backend/tests/test_2800_per_source_probe.py +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -153,28 +153,38 @@ async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.Monke @pytest.mark.parametrize( - ("source", "must_contain"), + ("source", "must_contain", "path_suffix"), [ - ("avito", "www.avito.ru"), - ("cian", "ekb.cian.ru"), - ("yandex", "realty.yandex.ru"), + ("avito", "www.avito.ru", "/robots.txt"), + ("cian", "ekb.cian.ru", "/robots.txt"), + ("yandex", "realty.yandex.ru", "/robots.txt"), # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. - ("domclick", "bff-search-web.domclick.ru"), + # + # #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*, + # robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4 + # в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по + # нему зелёная ровно тогда, когда свип получает блок. + ("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"), ], ) async def test_probe_asks_the_working_host_of_each_source( - monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str + monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str ) -> None: seen: dict[str, Any] = {} + # Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» — + # 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа. + _body = ( + '{"result":{"snippetsCount":678}}' if source == "domclick" else "
User-agent: *
" + ) class _Resp: status_code = 200 - text = '{"html": "
User-agent: *
"}' + text = "{}" @staticmethod def json() -> dict[str, str]: - return {"html": "
User-agent: *
"} + return {"html": _body} class _Client: def __init__(self, **_kw: Any) -> None: ... @@ -196,7 +206,10 @@ async def test_probe_asks_the_working_host_of_each_source( assert ok is True assert must_contain in seen["payload"]["url"] - assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет + # #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это + # боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть + # нагрузка на площадку остаётся минимальной, а защита — той же, что у работы. + assert path_suffix in seen["payload"]["url"] # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии. assert seen["payload"]["source"] == "generic" @@ -233,7 +246,10 @@ async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.Monkey assert ok is False # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. assert fail_kind == "page" - assert "not robots.txt" in detail + # #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от + # площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика). + # Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили. + assert "no marker 'User-agent'" in detail async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: @@ -454,3 +470,72 @@ async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPat lease = acquire(db, "domclick") # type: ignore[arg-type] assert lease is not None release(db, lease.id) # type: ignore[arg-type] + + +# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ── + + +@pytest.mark.parametrize( + ("api_body", "expect_ok"), + [ + # Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом. + # На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True, + # то есть «пара здорова» ровно там, где свип получает блок. Красный на main. + ("Ошибкаqrator captcha", False), + # Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность + # ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай + # и провалила бы этот, то есть тест не может позеленеть от глухого отказа. + # На origin/main этот случай тоже красный, но по другой причине — там проба + # спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА. + ('{"result":{"snippetsCount":678}}', True), + ], +) +async def test_domclick_probe_sees_the_block_that_robots_txt_hides( + monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool +) -> None: + """Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок. + + Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` → 05:02 свип + `QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и + 5 банов на все четыре площадки при ежедневном блоке Домклика. + """ + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = "{}" + + def __init__(self, body: str) -> None: + self._body = body + + def json(self) -> dict[str, str]: + return {"html": self._body} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["payload"] = json + asked = str(json["url"]) + # Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт. + if asked.endswith("/robots.txt"): + return _Resp("
User-agent: *
") + return _Resp(api_body) + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source="domclick" + ) + + assert ok is expect_ok + if not expect_ok: + # Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy". + assert fail_kind == "page" + # Спрашивали именно боевой путь, а не robots.txt того же хоста. + assert "/api/offers/" in seen["payload"]["url"] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 034de30f..e5421aec 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -61,7 +61,22 @@ _PROBE_URLS: dict[str, str] = { "avito": "https://www.avito.ru/robots.txt", "cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url "yandex": "https://realty.yandex.ru/robots.txt", # providers/yandex/serp.py::_GATE_URL - "domclick": "https://bff-search-web.domclick.ru/robots.txt", + # #2855: боевой ЗАЩИЩЁННЫЙ путь, а не robots.txt того же хоста. QRATOR закрывает + # /api/offers/*, а robots.txt отдаётся свободно — проба по нему возвращала 200 ровно + # тогда, когда свип с того же узла получал блок-страницу. Замер 13.08: за сутки + # 64 проверки пар и 5 банов на ВСЕ площадки, при этом свип Домклика блокировался + # каждые сутки; 04:30 проба «pair_banned=0» → 05:02 свип «QRATOR block». + # Взят count-эндпоинт: та же семья путей, что у работы (providers/domclick/serp.py + # ::_build_count_url), но ответ — одно число, без пагинации и без выдачи, то есть + # проба остаётся дешёвой для площадки (тот же довод, по которому здесь /fetch, а + # не /fetch-json). Параметры продублированы намеренно: импорт провайдера сюда дал + # бы цикл (providers/* уже импортируют browser_fetcher). + "domclick": ( + "https://bff-search-web.domclick.ru/api/offers/count/v1" + "?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561" + "&deal_type=sale&category=living&offer_type=flat&rooms=st" + "&sort=qi&sort_dir=desc" + ), } _PROXY_PROBE_URL: str = _PROBE_URLS["avito"] # Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool @@ -75,6 +90,11 @@ PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS) # странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали # отказ с кодом 200». _PROBE_CONTENT_MARKER: str = "User-agent" +# #2855: источник, чья проба идёт НЕ по robots.txt, требует своего признака «ресурс +# отдан». У count-эндпоинта Домклика это ключ ответа `snippetsCount` — он есть в живом +# JSON и отсутствует в блок-странице QRATOR. Держим отдельной картой, а не условием в +# теле: добавить площадку с боевым путём должно быть одной строкой, а не веткой. +_PROBE_CONTENT_MARKERS: dict[str, str] = {"domclick": "snippetsCount"} # source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес: # сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с # source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠ @@ -220,14 +240,16 @@ async def probe_proxy_via_browser( html = "" if not html: return False, classify_browser_probe(resp.status_code, detail), "empty html" - if _PROBE_CONTENT_MARKER not in html: - # 200 и непустое тело, но это не robots.txt — площадка отдала заглушку - # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»). + marker = _PROBE_CONTENT_MARKERS.get(source, _PROBE_CONTENT_MARKER) + if marker not in html: + # 200 и непустое тело, но это не запрошенный ресурс — площадка отдала заглушку + # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»; + # 13.08: Домклик отдаёт блок-страницу QRATOR на /api/offers/*, #2855). # Тракт узла исправен, негодна пара — отсюда "page", а не "proxy". return ( False, "page", - f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}", + f"no marker {marker!r} (html_len={len(html)}): {' '.join(html.split())[:120]}", ) return True, None, f"html_len={len(html)}" From 05d2126c5a6bade19e1ca96ff1ea05547124c4eb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 07:25:43 +0000 Subject: [PATCH 12/60] =?UTF-8?q?chore(tradein/db):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=82=D1=80=D0=B8=20=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=B8-=D0=B7=D0=B0=D0=B3=D0=BB=D1=83=D1=88?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20(#2857)=20(#2858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ngs_search_mv_drop_placeholder_columns.sql | 270 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + ...test_2857_search_mv_placeholder_columns.py | 145 ++++++++++ 3 files changed, 416 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql create mode 100644 tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py diff --git a/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql new file mode 100644 index 00000000..751f9e36 --- /dev/null +++ b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql @@ -0,0 +1,270 @@ +-- 261_listings_search_mv_drop_placeholder_columns.sql +-- Issue #2857 (эпик #2674) — снос трёх колонок-заглушек из listings_search_mv: +-- distance_to_metro_m, last_price_change, photos_count. +-- +-- Dependencies: 050_search_optimization.sql (завела витрину и 6 индексов), +-- 094_cadastral_unify.sql (последняя пересоздала витрину; её текст +-- и есть текущее прод-определение, сверено с pg_matviews 13.08.2026 — +-- расхождений нет), 088_scrape_schedules_seed_search_matview_refresh.sql +-- (суточный REFRESH ... CONCURRENTLY). +-- Apply after: 260_houses_drop_has_panorama.sql +-- Deploy order: схема и код независимы — у трёх колонок НЕТ читателей, поэтому +-- правки кода этот PR не несёт и порядок «миграция ↔ образ» безразличен. +-- +-- ── ЧТО ЗА НОЛЬ ──────────────────────────────────────────────────────────── +-- Не потеря данных и не оборванный писатель: NULL прописан в самом определении +-- витрины литералом. Четвёртый вид нуля — ОБЕЩАНИЕ В КОНТРАКТЕ БЕЗ РЕАЛИЗАЦИИ: +-- имена зарезервировали в 050, реализацию не подключили никогда. +-- +-- pg_stats по listings_search_mv, 13.08.2026 (45 310 строк): +-- null_frac = 1.0 у 5 колонок: cadastral_number, district, +-- distance_to_metro_m, last_price_change, photos_count. +-- Сносим три. После применения колонок с null_frac = 1.0 останется 2 +-- (cadastral_number — живая колонка с писателем, просто площадки её не отдают, +-- см. 216/search_query.py; district — вынесен решением владельца, ниже). +-- +-- ЧИТАТЕЛЕЙ НОЛЬ — перепроверено на origin/main, не по памяти: +-- `git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main` +-- даёт 8 строк, и все 8 — сами файлы 050 и 094 (объявление + комментарий над ним). +-- Ни бэкенда, ни фронта, ни тестов, ни скриптов. Отдельно проверено, что колонки +-- не уезжают в ответ через звёздочку: `SELECT *` из listings_search_mv в репозитории +-- НЕТ ни одного (единственный читатель — services/search_query.py, там явный +-- список из 27 имён), и SQLAlchemy-рефлексии витрины тоже нет. +-- +-- DISTRICT НЕ ТРОГАЕМ, хотя он такой же пустой. Он доехал дальше всех: его тянет +-- services/search_query.py:138 и объявляет schemas/search_response.py:44 +-- (`district: str | None`), то есть API его ОТДАЁТ — всегда null. Снос = ломающее +-- изменение контракта, решение владельца, вынесено отдельным пунктом в #2857. +-- Здесь он воспроизводится байт-в-байт (`NULL::text AS district`). +-- +-- ── ПОЧЕМУ DROP + CREATE, А НЕ ALTER ─────────────────────────────────────── +-- Материализованному представлению нельзя удалить колонку: ALTER MATERIALIZED VIEW +-- такой формы не имеет, а ALTER TABLE ... DROP COLUMN на relkind='m' отказывает. +-- Единственный путь — пересоздание, как в 094. +-- +-- БЕЗ CASCADE. Зависимых объектов на проде ноль (проверено через pg_depend/pg_rewrite +-- 13.08.2026: 0 строк). Если зависимость появится до применения — DROP упрётся и +-- деплой честно покраснеет; CASCADE снёс бы её молча. +-- +-- ── ГРАНТЫ: ЛОВУШКА, КОТОРАЯ ЗДЕСЬ НЕ СРАБАТЫВАЕТ, НО ПРИКРЫТА ───────────── +-- DROP уносит ACL вместе с объектом — это уже кусало (C3, FDW-гранты после +-- DROP ... CASCADE; 260 восстанавливала GRANT SELECT для gendesign_reader вручную). +-- На listings_search_mv восстанавливать сегодня НЕЧЕГО, и это измерено, а не +-- предположено: +-- pg_class.relacl = {tradein=arwdDxt/tradein} — только владелец, ни одного +-- стороннего grantee; column-level грантов нет; pg_default_acl пуст. +-- (information_schema.role_table_grants по витрине пуст ВСЕГДА и ничего не +-- доказывает: information_schema не показывает материализованные представления +-- в принципе — смотреть надо relacl. Это и есть тот источник, где ловушку легко +-- проглядеть.) +-- Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history — +-- на витрину ему не давали. +-- Тем не менее ACL снимается и переигрывается ниже автоматически: между написанием +-- файла и его применением на проде может пройти неделя, и ручной слепок к тому +-- моменту протухнет молча. Снимок берётся в той же транзакции, что и DROP, поэтому +-- врать не может. +-- +-- ── ИНДЕКСЫ ──────────────────────────────────────────────────────────────── +-- Пересоздаются все 6 (прод, 13.08.2026 — совпадают с 050/094 один в один). +-- UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него суточный +-- REFRESH MATERIALIZED VIEW CONCURRENTLY (app/tasks/refresh_search_matview.py, +-- расписание refresh_search_matview 03:00-04:00 UTC) упадёт с +-- «cannot refresh materialized view concurrently ... no unique index». +-- +-- ── ЦЕНА ПЕРЕСОЗДАНИЯ И БЛОКИРОВКА ───────────────────────────────────────── +-- Транзакция держит ACCESS EXCLUSIVE на витрине от DROP до COMMIT, т.е. читатели +-- ждут всё построение. Замер на проде (EXPLAIN ANALYZE тела витрины, 13.08.2026): +-- сам SELECT 6.6 s на прогретом кэше; плюс 6 индексов (GIN tsv 19 МБ, GIN trgm +-- 17 МБ, остальные мелочь) при maintenance_work_mem = 64 МБ — ориентир 30-60 s +-- на всю транзакцию. Для сравнения, суточный CONCURRENTLY-рефреш укладывается в +-- 9-17 s, но он делает вдвое больше работы (строит + сливает). +-- Простой READ-трафика приемлем: за всё время жизни БД (pg_stat_database.stats_reset +-- пуст, т.е. счётчики ни разу не сбрасывались) витрина видела 225 seq_scan и +-- 63 idx_scan — а суточный CONCURRENTLY-рефреш сам по себе даёт по seq_scan в день. +-- То есть /api/v1/search к ней практически не ходит, и трюк «собрать под временным +-- именем + переименовать» (12 лишних строк ради миллисекунд вместо минуты) не нужен. +-- +-- SET LOCAL lock_timeout = '5s' — ограничивает ОЖИДАНИЕ выдачи лока, не работу под +-- ним (см. .claude/rules/sql.md § lock_timeout). Ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами. Отдельный реальный конфликт здесь: если деплой +-- попадёт в окно 03:00-04:00 UTC, DROP столкнётся с REFRESH ... CONCURRENTLY → +-- честный красный деплой через 5 s, миграция не помечается применённой, повторный +-- деплой пройдёт. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP MATERIALIZED VIEW IF EXISTS + CREATE — повторный прогон приводит к тому +-- же состоянию (ценой ещё одного построения). Индексы создаются на заведомо +-- новом объекте, поэтому без IF NOT EXISTS (как в 050/094). +-- - Данных не теряем: витрина целиком выводима из listings/houses/listing_sources. +-- - Откат: вернуть три строки `NULL::...` в определение и пересоздать тем же +-- способом. Восстанавливать нечего — значений не существовало. +-- +-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения): +-- 1. Строка `261_listings_search_mv_drop_placeholder_columns.sql` в +-- _schema_migrations (а не «деплой зелёный»). +-- 2. Колонок в витрине 31 (было 34); distance_to_metro_m / last_price_change / +-- photos_count отсутствуют; district на месте, тип text. +-- 3. pg_matviews.definition не содержит подстроки 'distance_to_metro_m'. +-- 4. Индексов 6, среди них UNIQUE listings_search_mv_id_idx. +-- 5. pg_class.relacl витрины эквивалентен доприменительному (сегодня — владелец +-- и никого больше). +-- 6. SELECT count(*) FROM listings_search_mv отдаёт 40k+ строк. +-- 7. Следующий ночной refresh_search_matview завершается status='done' +-- (доказательство, что CONCURRENTLY не потерял UNIQUE-индекс). +-- 8. Ответ /api/v1/search по-прежнему содержит ключ district (и не содержит +-- удалённых — их там и не было). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование — в шапке. +SET LOCAL lock_timeout = '5s'; + +-- ── 1. Снимок ACL ДО сноса ───────────────────────────────────────────────── +-- aclexplode(NULL) даёт 0 строк — на витрине без явного ACL блок просто пуст. +-- Владельца исключаем: CREATE вернёт его права сам. +-- Колоночные гранты (pg_attribute.attacl) снимаются ОТДЕЛЬНОЙ веткой: они живут +-- не в relacl, и первая редакция этого файла их молча теряла — поймано прогоном +-- на одноразовой БД, а не рассуждением. +CREATE TEMP TABLE _mv2857_acl ON COMMIT DROP AS +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee, + a.privilege_type, + a.is_grantable, + NULL::text AS column_name +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +CROSS JOIN LATERAL aclexplode(c.relacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner +UNION ALL +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END, + a.privilege_type, + a.is_grantable, + quote_ident(att.attname) +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped +CROSS JOIN LATERAL aclexplode(att.attacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner; + +-- ── 2. Пересоздание витрины без трёх заглушек ────────────────────────────── +DROP MATERIALIZED VIEW IF EXISTS listings_search_mv; + +CREATE MATERIALIZED VIEW listings_search_mv AS +SELECT + l.id AS listing_id, + l.source, + l.source_url, + l.address, + l.geom, + l.lat, + l.lon AS lng, + l.rooms, + l.area_m2 AS total_area, + l.floor, + l.total_floors, + l.price_rub, + l.price_per_m2, + l.cadastral_number, + l.is_active, + l.scraped_at, + -- House denorm + h.id AS house_id, + h.year_built, + h.house_class, + h.developer_name, + h.rating AS house_rating, + h.reviews_count AS house_ratings_count, + -- Cross-source aggregates + (SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count, + (SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources, + (SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito, + (SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian, + (SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex, + -- Price percentile within house + (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2) + FROM listings ll + WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2, + -- Заглушка, оставленная СОЗНАТЕЛЬНО: district доезжает до схемы ответа API + -- (schemas/search_response.py), снос — ломающее изменение контракта, решение + -- владельца (#2857). Соседние distance_to_metro_m / last_price_change / + -- photos_count сняты здесь: у них не было ни одного читателя. + NULL::text AS district, + -- Trigram-ready columns + l.address AS address_trgm, + -- Aggregated tsv (description + address + developer_name) + to_tsvector('russian', + coalesce(l.description, '') || ' ' || + coalesce(l.address, '') || ' ' || + coalesce(h.developer_name, '') + ) AS tsv +FROM listings l +LEFT JOIN houses h ON h.id = l.house_id_fk +WHERE l.is_active = true + AND COALESCE(l.canonical, true) = true; + +-- ── 3. Те же 6 индексов (050/094) ────────────────────────────────────────── +-- UNIQUE — обязателен для REFRESH ... CONCURRENTLY, см. шапку. +CREATE UNIQUE INDEX listings_search_mv_id_idx + ON listings_search_mv (listing_id); + +CREATE INDEX listings_search_mv_geom_idx + ON listings_search_mv USING GIST (geom); + +CREATE INDEX listings_search_mv_filters_idx + ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC); + +CREATE INDEX listings_search_mv_address_trgm_idx + ON listings_search_mv USING GIN (address_trgm gin_trgm_ops); + +CREATE INDEX listings_search_mv_tsv_idx + ON listings_search_mv USING GIN (tsv); + +CREATE INDEX listings_search_mv_sources_idx + ON listings_search_mv (has_avito, has_cian, has_yandex); + +-- ── 4. Возврат грантов, снятых в п.1 ─────────────────────────────────────── +-- Пусто, если сторонних grantee не было (сегодня — так). privilege_type приходит +-- из системного каталога, поэтому подставляется как есть. +-- Если у кого-то окажется колоночный грант ИМЕННО на снесённую колонку — GRANT +-- упадёт на несуществующем имени, и это правильно: такой грант означает читателя, +-- которого мы не нашли, и деплой обязан покраснеть, а не молча снести колонку. +DO $$ +DECLARE + r record; +BEGIN + FOR r IN SELECT grantee, privilege_type, is_grantable, column_name FROM _mv2857_acl LOOP + EXECUTE format( + 'GRANT %s%s ON TABLE public.listings_search_mv TO %s%s', + r.privilege_type, + CASE WHEN r.column_name IS NULL THEN '' ELSE ' (' || r.column_name || ')' END, + r.grantee, + CASE WHEN r.is_grantable THEN ' WITH GRANT OPTION' ELSE '' END + ); + RAISE NOTICE 'listings_search_mv: возвращён GRANT % % для %', + r.privilege_type, coalesce('(' || r.column_name || ')', 'на витрину'), r.grantee; + END LOOP; +END +$$; + +-- ── 5. Статистика сразу, а не «когда-нибудь придёт autoanalyze» ──────────── +-- Иначе планировщик до первого автоанализа работает по пустым оценкам, а критерий +-- приёмки по pg_stats нечем проверить. +ANALYZE listings_search_mv; + +COMMENT ON MATERIALIZED VIEW listings_search_mv IS + 'Витрина поиска (/api/v1/search, 050/094). #2857: сняты три колонки-заглушки ' + 'distance_to_metro_m / last_price_change / photos_count — литеральный NULL в ' + 'определении, ноль читателей во всём репозитории. district оставлен намеренно: ' + 'он объявлен в schemas/search_response.py, его снос — ломающее изменение ' + 'контракта API и решение владельца. Единственный читатель витрины — ' + 'services/search_query.py с ЯВНЫМ списком колонок; SELECT * по ней запрещён ' + 'по той же причине, что и по market.v_houses.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 80ff61ee..6b449178 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -249,3 +249,4 @@ 258_houses_imv_transient_attempts.sql 259_data_quality_drop_pct_cadastr.sql 260_houses_drop_has_panorama.sql +261_listings_search_mv_drop_placeholder_columns.sql diff --git a/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py new file mode 100644 index 00000000..9df5b583 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py @@ -0,0 +1,145 @@ +"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674). + +`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо +в определении: district, distance_to_metro_m, last_price_change, photos_count. +Это не потеря данных и не оборванный писатель — имена зарезервировали, реализацию +не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты) +и они сняты миграцией 261; district оставлен намеренно — он объявлен в +schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее +изменение контракта (решение владельца, вынесено отдельно в #2857). + +Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её +актуального определения (самый старший NN среди файлов, создающих витрину) и +смотрит на состав списка. Переформатирование SQL, перестановка строк или смена +`NULL::int` на `NULL::integer` тест не трогают; возврат колонки — краснит. + +Без БД и сети: миграции читаются как текст, разбираются в структуру. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" + +MV = "listings_search_mv" +# Сняты 261: ноль читателей во всём репозитории на момент сноса. +DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count") + + +def _strip_sql_comments(sql: str) -> str: + sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) + return re.sub(r"--[^\n]*", "", sql) + + +def _latest_definition() -> str: + """Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид.""" + creators = [ + p + for p in SQL_DIR.glob("*.sql") + if re.search( + rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b", + _strip_sql_comments(p.read_text("utf-8")), + re.I, + ) + ] + assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда" + return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8") + + +def mv_columns() -> list[str]: + """Имена колонок витрины в порядке объявления. + + Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым + той же глубины, имя колонки — последний идентификатор элемента (алиас после + `AS` либо хвост `l.foo`). + """ + sql = _strip_sql_comments(_latest_definition()) + body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1] + + depth, items, cur = 0, [], [] + for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I): + t = token.group(0) + if t == "(": + depth += 1 + elif t == ")": + depth -= 1 + elif depth == 0 and t == ",": + items.append("".join(cur)) + cur = [] + continue + elif depth == 0 and t.upper() == "FROM": + break + cur.append(t) + items.append("".join(cur)) + + return [item.split()[-1].split(".")[-1] for item in items if item.split()] + + +def test_placeholder_columns_are_gone_from_the_matview() -> None: + """Red => витрина снова обещает поля, которых не заполняет (#2857). + + Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел + после возврата колонки — сначала заведи писателя, потом колонку, а не наоборот. + """ + cols = mv_columns() + still_there = [c for c in DROPPED if c in cols] + assert not still_there, ( + f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя " + "читается снаружи как «данные есть, просто у этого объекта пусто» — это " + "хуже мёртвого кода, потому что видно в контракте." + ) + + +def test_district_is_deliberately_kept() -> None: + """Red => district снесли заодно, а он в схеме ответа API. + + schemas/search_response.py объявляет `district: str | None`, services/search_query.py + его тянет — снос ломает контракт /api/v1/search. Это решение владельца (#2857), + а не побочный эффект уборки соседних заглушек. Убирать — вместе со схемой ответа. + """ + assert "district" in mv_columns(), ( + f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: " + "ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой." + ) + + +def test_search_api_selects_only_columns_the_matview_has() -> None: + """Настоящий инвариант: то, что просит API, витрина обязана иметь. + + Именно эта проверка отличает «список колонок» от «поиска подстроки»: она + краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх + известных именах. + """ + from app.schemas.search import SearchParams + from app.services.search_query import build_search_query + + sql, _ = build_search_query(SearchParams()) + selected = [ + c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip() + ] + missing = [c for c in selected if c not in mv_columns()] + assert not missing, ( + f"services/search_query.py просит у {MV} колонки, которых в её определении нет: " + f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из " + "schemas/search_response.py." + ) + + +def test_unique_index_for_concurrent_refresh_survives_recreation() -> None: + """Red => ночной REFRESH ... CONCURRENTLY упадёт. + + app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание + refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает + «cannot refresh materialized view concurrently ... no unique index» — а витрина, + которую пересоздали и забыли проиндексировать, молчит до самой ночи. + """ + sql = _strip_sql_comments(_latest_definition()) + assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), ( + f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — " + "REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен." + ) From d6c000eddb387afe7622a6b40216da7685b12625 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 07:33:59 +0000 Subject: [PATCH 13/60] =?UTF-8?q?docs(tradein):=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B5?= =?UTF-8?q?=D0=B5=20=D0=BE=D0=B1=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0=20=D0=94=D0=BE=D0=BC=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=B0=20(#2854)=20(#2859)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/tasks/domclick_detail_backfill.py | 17 ++++++++++++----- .../data/sql/260_houses_drop_has_panorama.sql | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 0e304651..00d91cf9 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -17,8 +17,9 @@ Both are wired together in the debug endpoint `POST /scrape/domclick/debug/detai wiring into the production scheduled orchestrator (previously only reachable manually). Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher -per run (async context manager, source="domclick" -- dedicated residential proxy pool, -see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via +per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула; +выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800), +на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via domclick_session.load_session(db) and threaded into every fetch_detail() call. NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere): @@ -40,9 +41,15 @@ Exception triad differs from Avito: Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished): блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными. - No IP-rotation/cooldown recovery step exists here - (DomClick uses one dedicated residential proxy, not a rotating pool) -- an - aborted run simply retries the remaining backlog next window. + No IP-rotation/cooldown recovery step exists here -- an aborted run simply + retries the remaining backlog next window. + УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated + residential proxy, not a rotating pool». Это перестало быть правдой на миграции + 253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть + отсутствие ротации больше НЕ следует из «ротировать нечего» — это просто + непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой + комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по + длительности до блока 111-332 с, получает свой бюджет). ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch diff --git a/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql index 58fcaecc..c53c6ce5 100644 --- a/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql +++ b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql @@ -1,4 +1,4 @@ --- 259_houses_drop_has_panorama.sql +-- 260_houses_drop_has_panorama.sql -- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области. -- -- Dependencies: 031_houses_alter_yandex.sql (завела колонку), From 9e83eb4a53de8939d4c80bb7afbd246913725fa3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 08:46:45 +0000 Subject: [PATCH 14/60] =?UTF-8?q?fix(tradein/ui):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=B6=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BA=D0=B8=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D0=BB=D1=81=D1=8F=20=D1=83=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=D1=85=2096=20974=20=E2=80=94=20=D1=82=D1=80=D0=B5?= =?UTF-8?q?=D0=B1=D0=BE=D0=B2=D0=B0=D0=BB=D0=BE=D1=81=D1=8C=20=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5=20(#2674)?= =?UTF-8?q?=20(#2861)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/trade-in/DealsCard.tsx | 11 +++- .../__tests__/DealsCardFloor.test.tsx | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/__tests__/DealsCardFloor.test.tsx diff --git a/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx index 9438f2e3..682e923c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx @@ -156,8 +156,15 @@ function DealRow({ deal }: { deal: AnalogLot }) { {deal.address} {deal.area_m2.toFixed(1)} м² · {deal.rooms === 0 ? "студия" : `${deal.rooms}-к`} - {deal.floor !== null && deal.total_floors !== null - ? ` · этаж ${deal.floor}/${deal.total_floors}` + {/* #2674: этажность у сделок Росреестра пуста ВСЕГДА (0 из 96 974 — + открытый набор её не содержит), а этаж заполнен у 96 974 из 96 974. + Прежнее условие требовало оба поля, поэтому этаж был скрыт у ВСЕХ + сделок. Печатаем «этаж N», когда этажности нет, — ровно так уже + делает BuildingListingsDrawer для объявлений. */} + {deal.floor !== null + ? deal.total_floors !== null + ? ` · этаж ${deal.floor}/${deal.total_floors}` + : ` · этаж ${deal.floor}` : ""} diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/DealsCardFloor.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/DealsCardFloor.test.tsx new file mode 100644 index 00000000..77dfffd3 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/DealsCardFloor.test.tsx @@ -0,0 +1,57 @@ +/** + * #2674 — этаж сделки скрывался у ВСЕХ сделок из-за требования второго поля. + * + * Замер на проде 13.08.2026: `deals.floor` заполнен у 96 974 из 96 974, + * `deals.total_floors` — у 0 из 96 974. Открытый набор Росреестра этажности не + * содержит в принципе, то есть это не «данные не доехали», а свойство источника. + * + * Прежнее условие печатало «этаж X/Y» только когда непусты ОБА поля, поэтому + * этаж не показывался никогда. Соседний компонент (BuildingListingsDrawer) уже + * делал правильно — печатал «этаж N», когда этажность неизвестна. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DealsCard } from "../DealsCard"; +import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; + +function makeDeal(overrides: Partial = {}): AnalogLot { + return { + address: "Екатеринбург, Ленина 5", + area_m2: 55.4, + rooms: 2, + floor: 7, + total_floors: null, + price_rub: 6_500_000, + price_per_m2: 117_328, + source: "rosreestr", + deal_date: "2026-01-01", + ...overrides, + } as AnalogLot; +} + +function makeEstimate(deals: AnalogLot[]): AggregatedEstimate { + return { + actual_deals: deals, + period_months: 12, + } as unknown as AggregatedEstimate; +} + +describe("DealsCard — этаж сделки", () => { + it("печатает этаж, когда этажность неизвестна (боевой случай: 100% сделок)", () => { + render(); + // Именно «этаж 7» без дроби — этажности у сделок Росреестра нет никогда. + expect(screen.getByText(/этаж 7/)).toBeTruthy(); + expect(screen.queryByText(/этаж 7\//)).toBeNull(); + }); + + it("печатает дробь, когда этажность известна — прежнее поведение цело", () => { + render(); + expect(screen.getByText(/этаж 7\/9/)).toBeTruthy(); + }); + + it("не печатает ничего, когда неизвестен сам этаж", () => { + render(); + expect(screen.queryByText(/этаж/)).toBeNull(); + }); +}); From 3fc406549e4a1da45d82d63c886d88ff6758f723 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 11:57:03 +0000 Subject: [PATCH 15/60] =?UTF-8?q?fix(ptica):=20=D0=B3=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B4=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B1=D0=B8=D1=8F=20=D1=83=20=D1=81=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=BD=D0=B5=D0=B9=20=D1=86=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?(#2464-D)=20(#2863)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 23 ++++++++++- .../api/v1/test_analyze_competitors_status.py | 38 +++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 191ed5c4..3a8a233a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2319,12 +2319,31 @@ def analyze_parcel( -- (303 строки = 303 distinct) → COUNT(*) по дедуп-физлотам корректен. SELECT np.domrf_obj_id, - ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub, + -- #2464-D: границы правдоподобия, как в двух соседних запросах + -- по этой же таблице (BETWEEN 30000 AND 600000) — здесь их не было. + -- Замер 13.08 по проду ЧЕРЕЗ ЭТОТ ЖЕ ПУТЬ (physflat-дедуп + + -- маппинг на domrf_obj_id): вне диапазона 204 лота из 2 279 827, + -- из них 118 в 10 замапленных проектах и 86 — в незамапленных. + -- Эффект сегодня МАЛЫЙ: меняются 6 проектов из 308, худший на + -- 2.4%, market_avg_price (среднее средних) 138 056 → 138 008; + -- NULL не появляется нигде. Ставим границы не ради этих 48 ₽, + -- а потому что среднее считается ПО ПРОЕКТУ и один лот держит + -- группу без ограничения сверху: максимум в таблице — + -- 19 198 429 ₽/м² (ЖК «Дебют»), и он вне экрана только потому, + -- что проект пока не замаплен (замаплено 308 имён из 881, список + -- растёт). Одна строка маппинга — и это число на экране. + -- FILTER, а не WHERE: строки нужны целиком, иначе поедут + -- units_sold / units_available, считающие ВСЕ лоты. + ROUND(AVG(oll.price_per_m2_rub) FILTER ( + WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000 + )::numeric, 0) AS avg_price_per_m2_rub, ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd, COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold, COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available, + -- Считаем ТУ ЖЕ популяцию, что кормит среднее: иначе счётчик + -- обещал бы выборку шире, чем на самом деле участвовала. COUNT(*) FILTER ( - WHERE oll.price_per_m2_rub IS NOT NULL + WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000 ) AS lots_with_price FROM nearby_projects np JOIN obj_lots_latest oll diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index d4fd67be..055a2d34 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -110,9 +110,9 @@ class TestCompetitorsSortOrder: sorted_rows = sorted(_ROWS_MIXED, key=_sort_key) first = dict(sorted_rows[0].items()) - assert first["site_status"] == "Строящиеся", ( - f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'" - ) + assert ( + first["site_status"] == "Строящиеся" + ), f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'" def test_flat_count_desc_would_break_order(self) -> None: """Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми.""" @@ -180,14 +180,44 @@ class TestObjPricingPushdown: #1964: источник агрегатов сменился с сырого objective_lots (alias ol) на physflat-дедуп CTE obj_lots_latest (alias oll) — см. test_obj_pricing_*_physflat ниже. Сами агрегатные выражения и группировка per-obj_id неизменны. + + #2464-D: у среднего цены появились границы правдоподобия (те же, что в двух + соседних запросах по objective_lots) — см. test_price_avg_has_sanity_bounds. """ sql = self._competitor_sql() - assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql + assert "AS avg_price_per_m2_rub" in sql assert "lots_with_price" in sql assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql assert "GROUP BY np.domrf_obj_id" in sql + def test_price_avg_has_sanity_bounds(self) -> None: + """#2464-D: среднее цены считается по лотам в границах правдоподобия. + + Среднее считается ПО ПРОЕКТУ, поэтому один лот держит группу без ограничения + сверху: максимум в objective_lots — 19.2 млн ₽/м² (замер 13.08). Границы + 30000..600000 уже стоят в двух соседних запросах по этой же таблице; здесь + их не было. Дальше значение уходит в market_avg_price и на экран. + """ + sql = self._competitor_sql() + bounds = "WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000" + assert ( + f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql + ), "среднее цены должно фильтроваться границами правдоподобия (#2464-D)" + # Тот же набор кормит счётчик выборки — иначе счётчик обещает шире, чем + # реально участвовало в среднем. + assert ( + f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql + ), "lots_with_price должен считать ту же популяцию, что и среднее" + # FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены + # молча урежут счётчики продаж/остатка, которые считают ВСЕ лоты. + assert ( + "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql + ), "units_sold не должен зависеть от границ цены" + assert ( + "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql + ), "units_available не должен зависеть от границ цены" + def test_obj_pricing_dedups_physflat_inline(self) -> None: """#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой. From 165dda599f02ccbc8cb24e28ae2a91f84ae7e476 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 12:26:44 +0000 Subject: [PATCH 16/60] =?UTF-8?q?docs(ptica):=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20beat-=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=20=D1=81=D0=B4=D0=B2=D0=B8=D0=B3?= =?UTF-8?q?=20=D0=9C=D0=A1=D0=9A=20=D0=B4=D0=B2=D0=B0=D0=B6=D0=B4=D1=8B=20?= =?UTF-8?q?(#2464-H)=20(#2866)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/workers/beat_schedule.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index ff2d812b..5c9eaa82 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -406,16 +406,17 @@ def build_beat_schedule() -> dict: # Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц. # kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д. - # Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления. + # Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта + # за ~5 недель полного обновления. # # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession - # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет + # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после # cooldown 24-48h (проверить через targeted test). # schedule["scrape-kn-catalog-objects-weekly"] = { # "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", - # "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC + # "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК # "kwargs": {"region_code": 66, "max_objects": 300}, # "options": {"queue": "celery"}, # } @@ -430,10 +431,10 @@ def build_beat_schedule() -> dict: # свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет. # Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом). # Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку — - # напр. четверг 04:00 UTC. + # напр. четверг 04:00 МСК. # schedule["scrape-kn-catalog-flats-weekly"] = { # "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats", - # "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC + # "schedule": _parse_cron("0 4 * * 4"), # четверг 04:00 МСК # "kwargs": {"region_code": 66, "max_flats": 300}, # "options": {"queue": "celery"}, # } @@ -542,13 +543,20 @@ def build_beat_schedule() -> dict: } # Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings. - # Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК). + # 00:30 МСК ежедневно (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233). + # Комментарий до #2464-H говорил «00:30 UTC = 03:30 МСК» — считал сдвиг дважды, + # оставшись с эпохи UTC-расписания. Факт по логам beat (10-12.08): «Sending due + # task newbuilding-crossload-nightly» в 21:30 UTC = 00:30 МСК, то есть на три + # часа раньше обещанного. + # Расписание НЕ трогаем: на 00:30 МСК ничего не наложено, а сдвиг на 03:30 МСК + # завёл бы задачу прямо в окно tradein-задания newbuilding_enrich (00:00-01:00 UTC + # = 03:00-04:00 МСК), с которым она делит источник — tradein.houses. # Не в job_settings (технический ETL, не требует конфигурации UI). # Идемпотентен через ON CONFLICT (source, ext_house_id). # Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения. schedule["newbuilding-crossload-nightly"] = { "task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload", - "schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК + "schedule": _parse_cron("30 0 * * *"), # 00:30 МСК "options": {"queue": "celery"}, } From 53bb769ea5ad9f441219bc6bf950deca3a9f388f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 12:26:51 +0000 Subject: [PATCH 17/60] =?UTF-8?q?fix(ptica):=20=D0=BD=D0=B5=D1=82=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=B7=D0=B0=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=20=E2=80=94=20=D1=86=D0=B5=D0=BD=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=200=20=E2=82=BD/=D0=BC=C2=B2,=20=D0=B0=20=C2=AB=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=C2=BB=20(#2?= =?UTF-8?q?464-B)=20(#2868)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/site_finder/best_layouts.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 9c6b73e9..aaee692b 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -192,15 +192,30 @@ _INLINE_VELOCITY_SQL = text(""" SELECT a.room_bucket, SUM(a.deals_window) AS deals_window, + -- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2 + -- объявлен как float (не Optional), и NULL ронял бы контракт API. + -- Пустые комнатности получают площадь 0 м², и это тоже неправда — но + -- честный NULL требует правки схемы + перегенерации типов фронта + -- и решения, что писать в area_bin. Отдельным заходом: #2867. COALESCE( SUM(a.area_weighted_sum) / NULLIF(SUM(a.deals_window), 0), 0 )::numeric(10, 2) AS avg_area_m2, - COALESCE( + -- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL → + -- средней цены нет, и это NULL, а не «0 ₽/м²». Схема так и объявлена + -- (TopLayoutRow.avg_price_per_m2_rub: float | None), и Python ниже уже + -- умеет None (пропускает строку во взвешенном роллапе) — но COALESCE + -- делал эту ветку недостижимой. + -- Замер 13.08 по проду, окно 6 месяцев. Сработает ноль или нет — зависит + -- от того, сколько замапленных проектов попало в радиус, поэтому цифры + -- по слоям: у 616 проектов 2083 пары (проект × комнатность), пустых 635; + -- 323 проекта имеют хотя бы одну пустую комнатность, 80 — пустые ВСЕ. + -- При объединении по два пустых остаётся 255 из 1267, по всему городу — + -- ноль. То есть чем беднее окрестность участка, тем чаще выдумывался 0. + ( SUM(a.price_weighted_sum) - / NULLIF(SUM(a.deals_window), 0), - 0 + / NULLIF(SUM(a.deals_window), 0) )::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub, array_agg(DISTINCT a.project_name) AS matched_project_names, MIN(a.window_start) AS window_start, From 92593404fad481f448dc019a5ff1c972d4900ac4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 12:48:28 +0000 Subject: [PATCH 18/60] =?UTF-8?q?ci:=20=D1=83=D0=B1=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20buildx-=D0=B1=D0=B8=D0=BB=D0=B4=D0=B5=D1=80=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=86=D0=B5=20=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D1=87=D0=BD=D1=8B=D1=85=20job'=D0=BE=D0=B2=20(#286?= =?UTF-8?q?9)=20(#2870)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/deploy-tradein.yml | 150 ++++++++++++++++++++++++++ .forgejo/workflows/deploy.yml | 150 ++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index d45dd831..50311309 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -227,8 +227,41 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push tradein-backend uses: docker/build-push-action@v6 @@ -251,6 +284,23 @@ jobs: ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + build-frontend: runs-on: ubuntu-latest needs: changes @@ -267,8 +317,41 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx # CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context # (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста, @@ -303,6 +386,23 @@ jobs: ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + build-browser: runs-on: ubuntu-latest needs: changes @@ -321,8 +421,41 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push tradein-browser uses: docker/build-push-action@v6 @@ -335,6 +468,23 @@ jobs: ${{ env.IMAGE_BROWSER }}:latest ${{ env.IMAGE_BROWSER }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + deploy: runs-on: ubuntu-latest needs: [changes, test, build-backend, build-frontend, build-browser] diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index f267444a..ebdcc75f 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -71,8 +71,41 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push backend (lean — без Chromium) uses: docker/build-push-action@v6 @@ -86,6 +119,23 @@ jobs: ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + build-worker: runs-on: ubuntu-latest needs: changes @@ -102,8 +152,41 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push worker (с Chromium для Playwright) uses: docker/build-push-action@v6 @@ -117,6 +200,23 @@ jobs: ${{ env.IMAGE_WORKER }}:latest ${{ env.IMAGE_WORKER }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + build-frontend: runs-on: ubuntu-latest needs: changes @@ -133,8 +233,41 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push frontend uses: docker/build-push-action@v6 @@ -150,6 +283,23 @@ jobs: ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + deploy: runs-on: ubuntu-latest needs: [changes, build-backend, build-worker, build-frontend] From a6e751da65a13d81381b72f9904a2dcf70b8ef19 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 16:53:45 +0000 Subject: [PATCH 19/60] =?UTF-8?q?ci:=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D1=8B=D0=BC=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=B2=D0=B0=20=D0=B1=D0=B5=D0=B7=D0=BC=D0=BE=D0=BB?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D1=85=20=D1=88=D0=B0=D0=B3=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20pytest=20(#2871)=20(#2872)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/ci.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 53e1d59b..a9cb7fa7 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -246,20 +246,34 @@ jobs: # если переменная пустая/файла нет, печатаем в обычный лог (fallback). if: always() run: | + echo "### шаг «Coverage summary» начался" [ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; } - report="$(uv run coverage report --skip-covered --sort=cover | tail -40)" + # NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с + # кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` — + # то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал + # неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден. + # `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после + # упавшей команды просто не выполнится, и код возврата снова потеряется. + cov_rc=0 + uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$? + echo "### coverage report вернул код $cov_rc" + report="$(tail -40 /tmp/cov_report.txt)" if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" else echo "$report" fi + echo "### шаг «Coverage summary» закончился успешно" - name: Снести тестовый Postgres # if: always() — контейнер уходит и когда сьют красный, и когда прогон # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. if: always() working-directory: . - run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + run: | + echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})" + docker rm -f "$CI_PG" >/dev/null 2>&1 || true + echo "### шаг «Снести тестовый Postgres» закончился успешно" frontend-tests: runs-on: ubuntu-latest From c6243b166fc5136fd340d5e5306e9d23bc60b5fb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 20:10:38 +0300 Subject: [PATCH 20/60] =?UTF-8?q?feat(mera):=20=D0=BE=D1=84=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=B0,=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D1=83=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=9F=D0=94=D0=BD=20+=20=D1=80=D0=B5=D0=BA=D0=B2?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Юридический блокер публичного B2C-запуска (G6 в mera-b2c-paid-flow-decision.md: `LEGAL_ENTITY == null` → нет реквизитов, нет продажи) и входное требование модерации эквайера: оферты не было вообще, а privacy-страница в собственной шапке писала, что она НЕ утверждённая политика по ст. 18.1 152-ФЗ. Что сделано: - content.ts: `LEGAL_ENTITY` заполнен (ООО «ПРОЕКТ ФЛЭТ», ИНН/КПП/ОГРН, адреса, директор, банковские реквизиты), добавлены `SUPPORT_EMAIL`, `SERVICE_PRICE_RUB`, пути и короткие публичные URL документов. Единый источник — подвал, оферта, возврат и ПДн рендерят эти поля, а не повторяют строки. ОГРН сверен с открытыми данными ЕГРЮЛ (в исходном сообщении владельца был с опечаткой …028028, верный …028281). - Новые страницы /mera-public/oferta и /mera-public/refund — редакции владельца от 13.08.2026 дословно, плейсхолдер `[адрес электронной почты]` заменён на support@meraocenka.ru. - privacy/page.tsx переписана на утверждённую редакцию с оператором и реквизитами приказа. Сохранены оба инварианта честности: раздел про страницу ввода адреса условен по `PUBLIC_ESTIMATE_ENABLED` (п. 5.4 — пока публичный расчёт выключен, адрес не покидает браузер), срок хранения оплаченного отчёта рендерится из `PAID_REPORT_RETENTION_MONTHS`, а не числом в тексте (test_paid_retention_text_consistency.py). - Caddyfile: короткие адреса /oferta, /refund, /privacy → rewrite на поддерево лэндинга. Именно они напечатаны внутри документов и уйдут в заявку эквайеру. Пути перечислены поимённо — allowlist-by-default периметра не ослаблен. - smoke-mera-perimeter.sh: три новые проверки на короткие адреса. Публикация оферты НЕ включает приём оплаты: платёжного контура в коде нет, `PUBLIC_ESTIMATE_ENABLED` по-прежнему false. Оферта публикуется раньше кнопки намеренно — без неё эквайер не примет заявку. Проверено локально: tsc, next lint, vitest (29), isolation guard, next build (три страницы пререндерены), pytest test_paid_retention_text_consistency (3), caddy validate + adapt (rewrite на месте), рендер всех трёх страниц через next start — реквизиты, почта и цена на месте. --- Caddyfile | 25 + scripts/smoke-mera-perimeter.sh | 11 + .../mera-public/_components/SiteFooter.tsx | 47 +- .../frontend/src/app/mera-public/content.ts | 113 ++++- .../src/app/mera-public/landing.module.css | 49 ++ .../src/app/mera-public/oferta/page.tsx | 480 ++++++++++++++++++ .../src/app/mera-public/privacy/page.tsx | 350 ++++++++----- .../src/app/mera-public/refund/page.tsx | 214 ++++++++ 8 files changed, 1143 insertions(+), 146 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/refund/page.tsx diff --git a/Caddyfile b/Caddyfile index 1e6a0e25..8624f14f 100644 --- a/Caddyfile +++ b/Caddyfile @@ -251,6 +251,31 @@ meraocenka.ru { } } + # Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ + # самих документов (оферта ссылается на meraocenka.ru/refund, политика + # возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому + # обязаны резолвиться сами по себе, а не только длинным + # /trade-in/mera-public/. Обратное направление тоже рабочее: длинный + # путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что + # то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где + # короткого /oferta нет. + # + # `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким + # — модератор эквайера открывает ссылку из заявки и видит ровно тот URL, + # который в ней указан. Каноничность для поисковиков задана отдельно, через + # `alternates.canonical` на каждой из трёх страниц. + # + # Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого + # site-блока — часть периметра (#2545), и превращать его в «любой корневой + # путь проксируется» ради трёх страниц нельзя. + @meraLegalDocs path /oferta /refund /privacy + handle @meraLegalDocs { + rewrite * /trade-in/mera-public{path} + reverse_proxy tradein-frontend:3000 { + header_up -X-Authenticated-User + } + } + # Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается # на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с # basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index cda7725a..99e53ef5 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -5,6 +5,8 @@ # 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг). # 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 — # политика ПДн, на которую ссылается футер. +# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL +# напечатаны внутри самих юридических документов и уходят эквайеру. # 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 — # allowlist-by-default, НЕ были случайно проброшены на B2B-дерево # tradein-frontend. Проверяются обе формы — с basePath-префиксом и без. @@ -52,6 +54,15 @@ check "meraocenka.ru root — public 200" "$BASE_MERA/" 200 # обязательный по 152-ФЗ документ станет недоступен с публичной страницы. check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200 +# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно +# эти три URL напечатаны внутри самих документов и уходят в заявку +# эквайеру — если rewrite выпадет из Caddyfile, оферта будет ссылаться на +# 404, и заявку завернут. Проверяем все три поимённо, потому что и в +# Caddyfile они перечислены поимённо (allowlist, не шаблон). +check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200 +check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200 +check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200 + # 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401. check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404 diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx index 086d4abc..c5d9b6e2 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx @@ -1,15 +1,17 @@ /** * SiteFooter — подвал. Серверный компонент. * - * Что здесь честно ОТСУТСТВУЕТ: - * - Реквизиты юрлица/ИП. В репозитории их нет (поиск по коду, бэкенду и - * разметке не дал ни наименования, ни ИНН/ОГРН), а выдумывать реквизиты - * оператора персональных данных на публичной странице нельзя. Блок - * рендерится, как только `LEGAL_ENTITY` в content.ts перестанет быть null; - * заполнить обязательно до открытия домена наружу — 152-ФЗ требует - * идентифицируемого оператора. - * - E-mail поддержки: реального адреса в коде тоже нет. Единственный - * проверяемый канал — телеграм-бот из `v2/SupportChatContext.tsx`. + * 2026-08-13: блоки реквизитов и почты, которых здесь раньше не было, теперь + * рендерятся — данные появились (`LEGAL_ENTITY`, `SUPPORT_EMAIL` в content.ts), + * и это не косметика: наименование, ИНН, ОГРН и работающий контакт на странице + * с офертой — прямое требование модерации эквайера и условие идентифицируемости + * оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же + * набор рендерят оферта и политика ПДн, и разъехаться они не должны. + * + * Ссылки на документы ведут по ВНУТРЕННИМ путям (`OFFER_PATH` и соседи), а не + * по коротким meraocenka.ru/oferta: короткие адреса существуют только на + * публичном хосте (rewrite в Caddy), а это же поддерево открывается и с + * gendsgn.ru/trade-in/mera-public — там короткая ссылка ушла бы в 404. * * Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без * валидации схемы) и открывается в новой вкладке с rel="noreferrer". @@ -21,8 +23,11 @@ import { safeUrl } from "@/lib/safeUrl"; import { LEGAL_ENTITY, + OFFER_PATH, PRIVACY_PATH, + REFUND_PATH, REGION_NAME, + SUPPORT_EMAIL, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL, } from "../content"; @@ -49,6 +54,12 @@ export function SiteFooter() {

Связаться

+

+ Почта:{" "} + + {SUPPORT_EMAIL} + +

{telegramHref ? (

Поддержка в Telegram:{" "} @@ -61,14 +72,22 @@ export function SiteFooter() { {SUPPORT_TELEGRAM_LABEL}

- ) : ( -

Контакты появятся к запуску.

- )} + ) : null}

Документы

    +
  • + + Публичная оферта + +
  • +
  • + + Политика возврата + +
  • Обработка персональных данных @@ -82,8 +101,8 @@ export function SiteFooter() { © {year} МЕРА {LEGAL_ENTITY && ( - {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn},{" "} - {LEGAL_ENTITY.address} + {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "} + {LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address} )} diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 5e5c2937..a2269a15 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -77,20 +77,119 @@ export const SUPPORT_TELEGRAM_URL = "https://t.me/MERAsupport_bot"; export const SUPPORT_TELEGRAM_LABEL = "@MERAsupport_bot"; /** - * Реквизиты оператора персональных данных (наименование юрлица/ИП, ИНН, адрес). - * `null` — потому что в репозитории их НЕТ: поиск по коду, бэкенду и разметке - * не дал ни ООО/ИП, ни ИНН/ОГРН. Выдумывать реквизиты на публичной странице - * нельзя, поэтому блок реквизитов просто не рендерится, пока значение null. - * Заполнить перед публичным запуском (обязательное требование 152-ФЗ). + * Почта поддержки — единственный письменный канал, названный в оферте, в + * политике возврата и в политике обработки ПДн. Один адрес на все три + * документа сознательно: разные адреса в оферте и в политике возврата — первое, + * за что цепляется модерация эквайера, и первое, что теряется при передаче + * обращения внутри компании. + * + * Домен именно `meraocenka.ru`, а не корпоративный `pr-flat.ru`: адрес стоит в + * публичных документах рядом с доменом сервиса и уходит в кассовый чек, где + * расхождение доменов читается как несовпадение продавца и сайта. + */ +export const SUPPORT_EMAIL = "support@meraocenka.ru"; + +/** + * Реквизиты Исполнителя (он же оператор персональных данных). Заполнены + * 2026-08-13 по данным владельца; ОГРН/ИНН/адрес сверены с открытыми данными + * ЕГРЮЛ (rusprofile, РБК Компании) — в исходном сообщении владельца ОГРН был с + * опечаткой (…028028), верный — …028281. + * + * ЭТО ЕДИНСТВЕННОЕ МЕСТО, где реквизиты живут на фронте: подвал, оферта, + * политика возврата и политика ПДн обязаны рендерить эти поля, а не повторять + * строки руками — иначе при смене адреса или директора страницы разъедутся, и + * разъедутся именно те, которые никто не перечитывает. + * + * `LEGAL_ENTITY !== null` — продуктовый гейт платного контура (G6 из + * `mera-b2c-paid-flow-decision.md`): нет реквизитов — нет продажи. Обратное + * неверно: заполненные реквизиты сами по себе НЕ включают приём оплаты (за это + * отвечает отдельный флаг платёжного контура, которого ещё нет). */ export const LEGAL_ENTITY: { + /** Полное фирменное наименование — как в ЕГРЮЛ. */ name: string; + /** Краткое наименование для строк, где полное не помещается (подвал). */ + shortName: string; inn: string; + kpp: string; + ogrn: string; + /** Юридический адрес одной строкой. */ address: string; -} | null = null; + /** Фактический адрес (для переписки), если отличается от юридического. */ + actualAddress: string; + /** ФИО директора в именительном падеже. */ + director: string; + bank: { + name: string; + /** Расчётный счёт. */ + account: string; + bik: string; + /** Корреспондентский счёт. */ + corrAccount: string; + }; +} | null = { + name: "Общество с ограниченной ответственностью «ПРОЕКТ ФЛЭТ»", + shortName: "ООО «ПРОЕКТ ФЛЭТ»", + inn: "6685046029", + kpp: "668501001", + ogrn: "1136685028281", + address: "620075, Свердловская область, г. Екатеринбург, ул. Малышева, стр. 51, офис 1", + actualAddress: "г. Екатеринбург, ул. Добролюбова, д. 3", + director: "Копылов Артём Николаевич", + bank: { + name: "АО «Т-Банк»", + account: "40702810910000104076", + bik: "044525974", + corrAccount: "30101810145250000974", + }, +}; -/** Внутренний маршрут страницы про обработку персональных данных. */ +/** + * Внутренние маршруты юридических страниц (то, что понимает next/link с + * basePath=/trade-in). Публично они же доступны короткими адресами + * meraocenka.ru/privacy, /oferta, /refund — короткие пути раздаёт Caddy + * (rewrite на это же поддерево), и ИМЕННО короткие напечатаны в самих + * документах и уйдут в заявку эквайеру. + * + * Навигация внутри сайта ходит по внутренним путям, а не по коротким: короткий + * `/oferta` существует только на хосте meraocenka.ru, а то же поддерево + * открывается и с gendsgn.ru/trade-in/mera-public — там ссылка на `/oferta` + * ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой + * странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже). + */ export const PRIVACY_PATH = "/mera-public/privacy"; +export const OFFER_PATH = "/mera-public/oferta"; +export const REFUND_PATH = "/mera-public/refund"; + +/** Канонический публичный origin сервиса — база для canonical-ссылок. */ +export const PUBLIC_ORIGIN = "https://meraocenka.ru"; + +/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */ +export const PUBLIC_PRIVACY_URL = `${PUBLIC_ORIGIN}/privacy`; +export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}/oferta`; +export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}/refund`; + +/** + * Цена одного расчёта, ₽. Названа в оферте (п. 4.1) и в политике возврата — + * поэтому живёт здесь, а не литералом в двух документах сразу. + * + * ⚠️ Публикация цены в оферте НЕ означает, что приём оплаты включён: + * платёжного контура в коде ещё нет, а `PUBLIC_ESTIMATE_ENABLED` выше по- + * прежнему `false`. Оферта опубликована раньше кнопки намеренно — её наличие + * с ценой и реквизитами является входным требованием модерации эквайера. + */ +export const SERVICE_PRICE_RUB = 150; + +/** Редакция юридических документов — одна дата на оферту и политику возврата. */ +export const LEGAL_DOCS_REVISION = "13 августа 2026 г."; + +/** + * Реквизиты приказа, которым утверждена политика обработки ПДн. Политика по + * ст. 18.1 152-ФЗ обязана быть именно УТВЕРЖДЁННЫМ документом оператора — + * страница без этой строки (как было до 2026-08-13) заявку эквайера не + * проходит и требованию закона не отвечает. + */ +export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г."; /** * Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта diff --git a/tradein-mvp/frontend/src/app/mera-public/landing.module.css b/tradein-mvp/frontend/src/app/mera-public/landing.module.css index a054771e..25d4d6b8 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing.module.css @@ -894,3 +894,52 @@ display: grid; gap: 6px; } + +/* Подзаголовок юридического документа: «Редакция от …» / «Утверждена приказом + …». Сидит вплотную под h1 — отсюда отрицательная компенсация верхнего + отступа заголовка не нужна, но собственный нижний отступ больше, чем у + обычного абзаца: строка отделяет шапку документа от его тела. */ +.docMeta { + margin: 0 0 20px; + font-size: 14px; + line-height: 1.5; + color: var(--m-muted); +} + +/* Блок реквизитов (dl) — оферта, раздел 13, и политика ПДн, раздел 2. + Двухколоночный grid на широком экране, одна колонка на узком: реквизиты + читают глазами по вертикали (ИНН, ОГРН, счёт), и перенос длинного номера + на вторую строку хуже, чем узкий столбец подписей. */ +.docRequisites { + margin: 0 0 10px; + display: grid; + gap: 8px; +} + +.docRequisites > div { + display: grid; + grid-template-columns: minmax(0, 11em) minmax(0, 1fr); + gap: 4px 14px; + align-items: baseline; +} + +@media (max-width: 520px) { + .docRequisites > div { + grid-template-columns: minmax(0, 1fr); + } +} + +.docRequisites dt { + font-size: 13px; + line-height: 1.5; + color: var(--m-muted); +} + +.docRequisites dd { + margin: 0; + font-size: 15px; + line-height: 1.6; + color: var(--m-body); + /* Номер счёта/ОГРН не должен растягивать страницу на узком экране. */ + overflow-wrap: anywhere; +} diff --git a/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx b/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx new file mode 100644 index 00000000..b5908fe3 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx @@ -0,0 +1,480 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { + LEGAL_DOCS_REVISION, + LEGAL_ENTITY, + PRIVACY_PATH, + PUBLIC_OFFER_URL, + PUBLIC_REFUND_URL, + REFUND_PATH, + SERVICE_PRICE_RUB, + SUPPORT_EMAIL, +} from "../content"; +import styles from "../landing.module.css"; + +/** + * Публичная оферта на оказание информационно-аналитических услуг «МЕРА». + * + * ОТКУДА ТЕКСТ. Редакция владельца от 13.08.2026 (docx), перенесённая сюда + * дословно; в исходнике на месте почты стоял плейсхолдер `[адрес электронной + * почты]` — подставлен `SUPPORT_EMAIL`, реквизиты в разделе 13 рендерятся из + * `LEGAL_ENTITY`. Ни одна формулировка не переписана «по смыслу»: это + * договорный документ, и расхождение с утверждённой владельцем редакцией — не + * стилистическая правка, а другой договор. + * + * ПОЧЕМУ ЭТО СТРАНИЦА, А НЕ PDF. Оферта обязана быть доступна по постоянному + * адресу без скачивания — это требование модерации эквайера и условие + * доказуемости акцепта (п. 2.2: акцепт = оплата, значит текст на момент оплаты + * должен быть публично зафиксирован). Короткий адрес meraocenka.ru/oferta + * раздаёт Caddy (rewrite на этот маршрут) — именно он напечатан в самом + * документе, в политике возврата и уйдёт в заявку эквайеру. + * + * ЧЕГО ЗДЕСЬ НАМЕРЕННО НЕТ: + * - Ставки НДС числом. П. 4.1 говорит «включая НДС по ставке, применяемой + * Исполнителем в соответствии с действующим законодательством» — у + * владельца УСН «доходы-расходы» с НДС 5% на 2026 год и другой режим с + * 2027-го; формулировка переживает переход без переиздания оферты, а + * конкретная ставка живёт там, где обязана — в реквизитах кассового чека. + * - Обещания PDF-отчёта и личного кабинета: платёжного контура в коде ещё + * нет, состав Результата описан ровно так, как его описывает раздел 6. + * + * СВЯЗЬ С КОДОМ. Цена (`SERVICE_PRICE_RUB`) и реквизиты (`LEGAL_ENTITY`) + * импортируются из `content.ts`, а не пишутся строкой: те же значения рендерит + * подвал и политика возврата, и разъехаться они не должны. + */ + +export const metadata: Metadata = { + title: "Публичная оферта — МЕРА", + description: + "Публичная оферта на оказание информационно-аналитических услуг сервиса «МЕРА».", + alternates: { canonical: PUBLIC_OFFER_URL }, +}; + +export default function MeraPublicOfferPage() { + const entity = LEGAL_ENTITY; + + return ( +
    +
    + + ← На главную + + +

    Публичная оферта

    +

    + на оказание информационно-аналитических услуг сервиса «МЕРА» + (meraocenka.ru). Редакция от {LEGAL_DOCS_REVISION} +

    + + {!entity ? ( +

    + Реквизиты Исполнителя не опубликованы — до их публикации оферта не + действует и оплата на сайте не принимается. +

    + ) : ( + <> +

    1. Термины и определения

    +

    + 1.1. «Исполнитель» — {entity.name}, реквизиты которого указаны в + разделе 13 настоящей Оферты. +

    +

    + 1.2. «Сайт» — интернет-сайт, расположенный по адресу + meraocenka.ru, включая все его страницы и поддомены. +

    +

    + 1.3. «Сервис» или «МЕРА» — программа для ЭВМ, размещённая на Сайте + и предназначенная для автоматизированного формирования + индикативного расчёта ориентировочной рыночной стоимости объекта + недвижимости на основании характеристик, введённых пользователем. +

    +

    + 1.4. «Заказчик» — любое дееспособное физическое лицо либо + юридическое лицо / индивидуальный предприниматель, оплатившее + Услугу на условиях настоящей Оферты. +

    +

    + 1.5. «Услуга» — оказываемая Исполнителем услуга по формированию и + предоставлению Заказчику Результата по одному обращению к Сервису. +

    +

    + 1.6. «Результат» или «Расчёт» — сформированное Сервисом + индикативное значение ориентировочной рыночной стоимости объекта + недвижимости, отображаемое в интерфейсе Сайта и/или направляемое + на электронную почту Заказчика. +

    +

    + 1.7. «Оферта» — настоящий документ, размещённый в свободном + доступе в сети Интернет по адресу {PUBLIC_OFFER_URL}. +

    + +

    2. Общие положения

    +

    + 2.1. Настоящая Оферта является публичным предложением Исполнителя + заключить договор оказания услуг на изложенных ниже условиях в + соответствии со ст. 435 и п. 2 ст. 437 Гражданского кодекса РФ. +

    +

    + 2.2. Оферта считается акцептованной Заказчиком, а договор — + заключённым на условиях настоящей Оферты с момента совершения + Заказчиком действий, свидетельствующих об акцепте, а именно — + оплаты стоимости Услуги в порядке, установленном разделом 4 + настоящей Оферты (ст. 438 ГК РФ). +

    +

    + 2.3. Заказчик, не согласный с условиями настоящей Оферты, не + вправе производить оплату Услуги и обязан покинуть Сайт. +

    +

    + 2.4. К отношениям Сторон, не урегулированным настоящей Офертой, + применяется законодательство Российской Федерации, включая, если + Заказчиком является физическое лицо, действующее для личных, + семейных, бытовых нужд, не связанных с предпринимательской + деятельностью, — Закон РФ от 07.02.1992 № 2300-1 «О защите прав + потребителей». +

    + +

    3. Предмет Оферты

    +

    + 3.1. Исполнитель обязуется оказать Заказчику Услугу — сформировать + и предоставить Заказчику доступ к Результату, а Заказчик обязуется + оплатить Услугу в порядке и на условиях, установленных настоящей + Офертой. +

    +

    + 3.2. Услуга оказывается дистанционно, посредством сети Интернет, + без необходимости личного присутствия Заказчика. +

    +

    + 3.3. Характер и правовой статус Результата определены разделом 6 + настоящей Оферты — Заказчику надлежит ознакомиться с ним до + совершения оплаты. +

    + +

    4. Стоимость Услуги и порядок оплаты

    +

    + 4.1. Стоимость одной Услуги (одного Расчёта) составляет{" "} + {SERVICE_PRICE_RUB} (сто пятьдесят) рублей 00 копеек, включая НДС + по ставке, применяемой Исполнителем в соответствии с действующим + законодательством РФ. +

    +

    + 4.2. Оплата производится Заказчиком в безналичном порядке через + платёжную форму на Сайте с использованием банковских карт + платёжных систем и/или иных способов оплаты, поддерживаемых + платёжным сервисом, подключённым Исполнителем. +

    +

    + 4.3. Обязанность Заказчика по оплате Услуги считается исполненной + с момента зачисления денежных средств на расчётный счёт + Исполнителя либо на счёт платёжного сервиса, обслуживающего + расчёты Исполнителя. +

    +

    + 4.4. Исполнитель направляет Заказчику кассовый чек в соответствии + с Федеральным законом от 22.05.2003 № 54-ФЗ на номер телефона или + адрес электронной почты, указанные Заказчиком при оплате. Заказчик + обязан указать корректные и действующие контактные данные для + получения чека. +

    +

    + 4.5. Стоимость Услуги может быть изменена Исполнителем в + одностороннем порядке путём публикации новой редакции Оферты на + Сайте. Новая стоимость применяется к Услугам, оплаченным после + публикации новой редакции, и не имеет обратной силы. +

    + +

    5. Момент оказания Услуги

    +

    + 5.1. Услуга считается оказанной Исполнителем в полном объёме и + надлежащим образом с момента наступления любого из следующих + событий, в зависимости от того, какое из них наступит раньше: +

    +
      +
    • + отображения Результата в интерфейсе Сайта Заказчику после ввода + характеристик объекта и подтверждения оплаты; +
    • +
    • + направления Результата на адрес электронной почты, указанный + Заказчиком при оплате. +
    • +
    +

    + 5.2. Ввиду массового и автоматизированного характера Услуги + отдельный акт оказания услуг Сторонами не составляется и не + подписывается. Факт формирования и предоставления Результата + фиксируется автоматически в системе Сайта (запись в системном + журнале с указанием даты, времени и идентификатора операции) и + является достаточным и надлежащим доказательством оказания Услуги + в полном объёме. +

    +

    + 5.3. Услуга оплачивается Заказчиком за факт формирования и + предоставления Результата, а не за конкретное числовое значение, + определённое Сервисом, и не за соответствие такого значения + ожиданиям Заказчика или дальнейшим действиям Заказчика на его + основании. Характер и ограничения Результата установлены разделом + 6 настоящей Оферты. +

    +

    + 5.4. Заказчик вправе направить Исполнителю мотивированное + обращение относительно факта оказания Услуги (то есть относительно + того, был ли Результат фактически сформирован и предоставлен) в + течение 24 (двадцати четырёх) часов с момента, определённого п. + 5.1 настоящей Оферты. По истечении указанного срока Услуга + считается оказанной Исполнителем в полном объёме и принятой + Заказчиком без замечаний к факту и полноте её оказания. +

    +

    + 5.5. Если по техническим причинам на стороне Сайта или Сервиса + Результат не был сформирован либо не был предоставлен Заказчику в + течение 15 (пятнадцати) минут с момента подтверждения оплаты по п. + 4.3, Услуга считается неоказанной. В этом случае применяется + порядок возврата денежных средств, установленный п. 8.3 настоящей + Оферты. +

    + +

    + 6. Характер и статус Результата — ограничение ответственности +

    +

    + 6.1. Результат, формируемый Сервисом и предоставляемый Заказчику, + является результатом автоматизированной обработки введённых + Заказчиком данных и носит исключительно индикативный + (ориентировочный, вероятностный) информационно-аналитический + характер. +

    +

    + 6.2. Результат не является отчётом об оценке по смыслу + Федерального закона от 29.07.1998 № 135-ФЗ «Об оценочной + деятельности в Российской Федерации», не составляется субъектом + оценочной деятельности и не обладает юридической силой, + установленной указанным законом для отчёта об оценке. +

    +

    + 6.3. Результат не предназначен для использования и не может быть + использован в качестве обязательного к применению значения + рыночной стоимости объекта недвижимости в случаях, когда + законодательством РФ предусмотрено обязательное проведение оценки, + в том числе для целей: ипотечного кредитования; судебных + разбирательств; нотариальных действий; сделок с участием + государственного или муниципального имущества; иных случаев, прямо + предусмотренных законодательством РФ. +

    +

    + 6.4. Точность Результата зависит от полноты и достоверности + характеристик объекта, введённых Заказчиком. Исполнитель не + проверяет и не может проверить достоверность введённых Заказчиком + данных и не несёт ответственности за отклонение Результата от + фактической рыночной стоимости объекта, вызванное + недостоверностью, неполнотой или неточностью введённых данных. +

    +

    + 6.5. Исполнитель не несёт ответственности за любые решения, + действия или бездействие Заказчика либо третьих лиц, основанные на + Результате, включая решения о покупке, продаже, зачёте объекта + недвижимости по программам трейд-ин или иным образом. +

    + +

    7. Права и обязанности Сторон

    +

    + 7.1. Исполнитель обязуется обеспечить функционирование Сервиса и + предоставить Заказчику Результат при условии поступления оплаты и + корректного заполнения Заказчиком формы ввода характеристик + объекта. +

    +

    + 7.2. Заказчик обязуется предоставлять достоверные характеристики + объекта для формирования Результата и указывать корректные + контактные данные, необходимые для направления кассового чека. +

    +

    + 7.3. Исполнитель вправе приостановить оказание Услуги на время + проведения технических, профилактических и иных работ, необходимых + для поддержания работоспособности Сайта и Сервиса. +

    +

    + 7.4. Исполнитель вправе в одностороннем порядке изменять условия + настоящей Оферты с публикацией новой редакции на Сайте не менее + чем за 3 (три) календарных дня до её вступления в силу, за + исключением случаев, требующих немедленного изменения в силу + закона. +

    + +

    8. Порядок возврата денежных средств

    +

    + 8.0. Условия настоящего раздела дублируются и подробно раскрыты в + отдельном документе —{" "} + + Политике возврата денежных средств + + , размещённой по адресу {PUBLIC_REFUND_URL}. В случае расхождений + между настоящим разделом и указанным документом применяется + настоящая Оферта. +

    +

    + 8.1. Общее правило. Услуга, оказанная в соответствии с разделом 5 + настоящей Оферты, считается исполненной Исполнителем в полном + объёме. После наступления момента, установленного п. 5.1, оплата + возврату не подлежит, за исключением случаев, прямо + предусмотренных настоящим разделом и законодательством РФ. +

    +

    + 8.2. Отказ от Услуги до её оказания. В соответствии со ст. 782 + Гражданского кодекса РФ Заказчик вправе отказаться от Услуги в + любой момент до наступления момента её оказания, установленного п. + 5.1 настоящей Оферты, уведомив Исполнителя по контактным данным, + указанным в разделе 13. В этом случае уплаченные денежные средства + возвращаются Заказчику в полном объёме, поскольку формирование + Результата не влечёт для Исполнителя расходов, подлежащих + отдельному возмещению за счёт Заказчика. +

    +

    + 8.3. Технический сбой (Услуга не оказана). Если Результат не был + предоставлен Заказчику по причинам, указанным в п. 5.5 настоящей + Оферты, денежные средства возвращаются Заказчику в полном объёме + автоматически, без необходимости подачи Заказчиком отдельного + заявления или обращения, в срок не позднее 3 (трёх) рабочих дней с + момента фиксации Сервисом факта неоказания Услуги. +

    +

    + 8.4. Иные основания возврата. Денежные средства также подлежат + возврату в полном объёме в случаях: повторного списания денежных + средств вследствие технической ошибки платёжной формы Сайта; + списания денежных средств по операции, не санкционированной + Заказчиком, — при подтверждении данного факта банком-эмитентом + карты Заказчика. +

    +

    + 8.5. Для возврата денежных средств по основаниям п. 8.4 Заказчик + направляет обращение на {SUPPORT_EMAIL}, указав дату и + приблизительное время оплаты, сумму и последние 4 цифры номера + карты, с которой производилась оплата. Исполнитель рассматривает + обращение и принимает решение в срок не позднее 10 (десяти) + рабочих дней с момента его получения. +

    +

    + 8.6. Возврат денежных средств во всех случаях производится тем же + способом, которым была произведена оплата, если иной способ не + согласован Сторонами дополнительно. +

    +

    + 8.7. Настоящий раздел не ограничивает права Заказчика, + являющегося потребителем по смыслу Закона РФ от 07.02.1992 № + 2300-1 «О защите прав потребителей»: в части, в которой условия + настоящего раздела противоречили бы правам, гарантированным + Заказчику указанным Законом, применяются положения Закона. +

    + +

    9. Персональные данные

    +

    + 9.1. Обработка персональных данных Заказчика осуществляется + Исполнителем в соответствии с{" "} + + Политикой обработки персональных данных + + , размещённой на Сайте и являющейся неотъемлемой частью настоящей + Оферты. +

    + +

    10. Ответственность Сторон

    +

    + 10.1. За неисполнение или ненадлежащее исполнение обязательств по + настоящей Оферте Стороны несут ответственность в соответствии с + законодательством РФ. +

    +

    + 10.2. Совокупная ответственность Исполнителя перед Заказчиком по + настоящей Оферте ограничена суммой, уплаченной Заказчиком за + соответствующую Услугу, за исключением случаев, когда + законодательством РФ установлено иное (в частности, в отношении + прав потребителей). +

    +

    + 10.3. Исполнитель не несёт ответственности за невозможность + оказания Услуги вследствие обстоятельств непреодолимой силы, а + также сбоев в работе сетей связи, интернет-провайдеров, платёжных + систем и иных третьих лиц, не находящихся под контролем + Исполнителя. +

    + +

    11. Срок действия Оферты

    +

    + 11.1. Оферта действует бессрочно с момента размещения на Сайте до + момента её отзыва Исполнителем либо замены новой редакцией. +

    + +

    12. Разрешение споров

    +

    + 12.1. Стороны стремятся разрешить любые споры и разногласия путём + переговоров с обязательным направлением письменной претензии. Срок + ответа на претензию — 10 (десять) рабочих дней с момента + получения. +

    +

    + 12.2. При недостижении согласия спор передаётся на рассмотрение + суда по месту нахождения Исполнителя, за исключением случаев, + когда императивными нормами законодательства о защите прав + потребителей установлена альтернативная подсудность по выбору + потребителя. +

    + +

    13. Реквизиты Исполнителя

    +
    +
    +
    Наименование
    +
    {entity.name}
    +
    +
    +
    Юридический адрес
    +
    {entity.address}
    +
    +
    +
    Фактический адрес
    +
    {entity.actualAddress}
    +
    +
    +
    ИНН / КПП
    +
    + {entity.inn} / {entity.kpp} +
    +
    +
    +
    ОГРН
    +
    {entity.ogrn}
    +
    +
    +
    Расчётный счёт
    +
    {entity.bank.account}
    +
    +
    +
    Банк
    +
    + {entity.bank.name}, БИК {entity.bank.bik}, к/с{" "} + {entity.bank.corrAccount} +
    +
    +
    +
    E-mail
    +
    + + {SUPPORT_EMAIL} + +
    +
    +
    +
    Директор
    +
    {entity.director}
    +
    +
    + + )} +
    +
    + ); +} 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 0cdd50cf..b9e2dd94 100644 --- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx @@ -4,7 +4,10 @@ import Link from "next/link"; import { LEGAL_ENTITY, PAID_REPORT_RETENTION_MONTHS, + PRIVACY_APPROVAL, PUBLIC_ESTIMATE_ENABLED, + PUBLIC_PRIVACY_URL, + SUPPORT_EMAIL, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL, } from "../content"; @@ -12,64 +15,47 @@ import styles from "../landing.module.css"; import { safeUrl } from "@/lib/safeUrl"; /** - * Страница «Обработка персональных данных» для публичного лэндинга. + * Политика обработки персональных данных по ст. 18.1 152-ФЗ. * - * ЧТО ЭТО ЗА ДОКУМЕНТ. Это НЕ утверждённая политика по ст. 18.1 152-ФЗ: - * полноценная политика обязана называть оператора (наименование, ИНН, адрес), а - * этих данных в проекте нет — см. `LEGAL_ENTITY` в content.ts. Выдумывать - * реквизиты на публичной странице нельзя, поэтому здесь — честное описание - * того, что сервис делает с данными СЕГОДНЯ по факту кода: + * ЧТО ИЗМЕНИЛОСЬ 2026-08-13. До этой редакции страница честно объявляла себя + * НЕ утверждённой политикой: у проекта не было ни реквизитов оператора, ни + * приказа об утверждении, и выдумывать их было нельзя. Теперь есть и то, и + * другое (`LEGAL_ENTITY`, `PRIVACY_APPROVAL` в content.ts), поэтому здесь — + * утверждённая владельцем редакция (docx от 13.08.2026), перенесённая + * дословно. Прежняя оговорка «политика не утверждена» снята не потому, что + * стала неудобной, а потому, что перестала быть правдой. * - * - расчёт: адрес + параметры квартиры (`TradeInEstimateInput`); - * - заявка: телефон + явное согласие галочкой (`v2/LeadForm.tsx` → - * `POST /api/v1/trade-in/lead`), факт согласия сохраняется отдельно; - * - сама эта страница не делает ни одного запроса к API и не подключает - * счётчики (проверено: RouteGuard отдаёт публичный путь до useMe, - * `app/providers.tsx` поднимает только пустой QueryClient, шрифты - * self-hosted через next/font). + * ЧТО ОСТАЛОСЬ ОТ ПРЕЖНЕЙ СТРАНИЦЫ — правило честности: + * - Раздел про страницу ввода адреса УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`. + * Пока публичный расчёт выключен, адрес физически не покидает браузер, и + * писать обратное нельзя; текст меняется вместе с флагом, а не потом. + * - Срок хранения оплаченного отчёта рендерится из + * `PAID_REPORT_RETENTION_MONTHS`, а НЕ числом, вписанным в текст, — это + * гейтится тестом `test_paid_retention_text_consistency.py` (бэкендовая + * настройка `trade_in_paid_retention_days` и публичное обещание обязаны + * двигаться вместе). + * - Механизм удаления описан как существующий, потому что он существует: + * `app/services/data_erasure.py` (по обращению) и + * `app/tasks/purge_expired_trade_in_data.py` (по истечении срока). + * Известное ограничение — копия сообщения в Telegram-группе поддержки этим + * механизмом не стирается (см. докстринг `data_erasure.py`); на публичной + * странице это не разворачивается, но при обращении об удалении оператор + * обязан помнить. * - * ЧЕГО ЗДЕСЬ СОЗНАТЕЛЬНО НЕ НАПИСАНО: - * - «адрес и параметры квартиры — это не данные о вас». Правовая - * квалификация — не наше дело: заявка привязывается к конкретному расчёту - * (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее - * сохранённым адресом. Финальную формулировку даёт юрист. - * - «мы обещаем удалить всё вообще» без оговорок. После #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`: пока - * расчёт выключен, адрес действительно не покидает браузер; после включения это - * перестанет быть правдой, и текст должен смениться вместе с флагом, а не - * когда-нибудь потом. - * - * Перед открытием домена наружу текст обязан быть заменён на утверждённую - * политику с реквизитами оператора. + * Короткий публичный адрес — meraocenka.ru/privacy (rewrite в Caddy); именно он + * напечатан в самой политике и в оферте. */ export const metadata: Metadata = { - title: "Обработка персональных данных — МЕРА", - robots: { index: false, follow: false }, + title: "Политика обработки персональных данных — МЕРА", + description: + "Утверждённая политика обработки персональных данных сервиса «МЕРА» по ст. 18.1 152-ФЗ.", + alternates: { canonical: PUBLIC_PRIVACY_URL }, }; export default function MeraPublicPrivacyPage() { const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); + const entity = LEGAL_ENTITY; return (
    @@ -81,103 +67,217 @@ export default function MeraPublicPrivacyPage() { ← На главную -

    Обработка персональных данных

    - -

    - Здесь по-человечески описано, какие данные нужны сервису «Мера», зачем - и что с ними происходит. +

    + Политика обработки персональных данных +

    +

    + сервис «МЕРА» (meraocenka.ru). Утверждена {PRIVACY_APPROVAL}

    -

    Что нужно для оценки

    +

    1. Общие положения

    - Адрес дома и параметры квартиры: площадь, этаж, число комнат, тип - дома, состояние. Имя, паспорт и документы на квартиру мы не - спрашиваем, и сами по себе эти сведения описывают объект недвижимости. + 1.1. Настоящая Политика определяет порядок обработки персональных + данных и меры по обеспечению их безопасности в{" "} + {entity ? entity.shortName : "организации-операторе"} (далее — + «Оператор») в целях защиты прав и свобод пользователей сайта + meraocenka.ru (далее — «Сайт», «Сервис»).

    - При этом мы не делаем вид, что связи с вами нет совсем: если вы потом - оставите заявку, ваш телефон будет привязан именно к этому расчёту — - то есть к конкретному адресу. Поэтому телефон и обращаемся с ним как с - персональными данными, с отдельным согласием. + 1.2. Политика разработана и утверждена в соответствии со ст. 18.1 + Федерального закона от 27.07.2006 № 152-ФЗ «О персональных данных» и + является общедоступным документом, размещаемым на Сайте без + ограничения доступа по адресу {PUBLIC_PRIVACY_URL}.

    -

    Когда появляется телефон

    -

    - Только если вы сами решите оставить заявку и поставите отдельную - галочку согласия на обработку персональных данных в соответствии с - Федеральным законом № 152-ФЗ. Без этой галочки заявка не отправляется. -

    -

    - Телефон используется, чтобы связаться с вами по вашей же заявке. - Вместе с ним сохраняется сам факт согласия — когда именно и на каком - тексте оно было дано. -

    - -

    Что делает эта страница

    - {PUBLIC_ESTIMATE_ENABLED ? ( -

    - Введённый адрес и параметры квартиры уходят на наш сервер, чтобы по - ним посчитать оценку, и сохраняются вместе с результатом расчёта — - иначе отчёт нельзя было бы открыть повторно. Счётчиков и рекламных - пикселей на странице нет, шрифты отдаются с нашего домена, а не со - сторонних сервисов. -

    +

    2. Оператор персональных данных

    + {entity ? ( +
    +
    +
    Наименование
    +
    {entity.name}
    +
    +
    +
    ИНН / КПП / ОГРН
    +
    + {entity.inn} / {entity.kpp} / {entity.ogrn} +
    +
    +
    +
    Юридический адрес
    +
    {entity.address}
    +
    +
    +
    Директор
    +
    {entity.director}
    +
    +
    ) : (

    - Ничего не отправляет. Пока публичная оценка не открыта, форма адреса - работает только в браузере: введённый адрес никуда не уходит и нигде - не сохраняется. Счётчиков и рекламных пикселей на странице нет, - шрифты отдаются с нашего домена, а не со сторонних сервисов. + Реквизиты оператора не опубликованы — до их публикации политика не + считается утверждённой.

    )} -

    Как отозвать согласие

    +

    3. Какие данные мы обрабатываем и зачем

    - Напишите нам в поддержку — обращение об отзыве согласия мы принимаем и - разбираем вручную, после чего перестаём использовать ваш телефон для - связи по заявке.{" "} - {telegramHref ? ( - <> - Канал связи:{" "} - - {SUPPORT_TELEGRAM_LABEL} - - . - - ) : null} + 3.1. Для получения расчёта пользователь вводит адрес объекта + недвижимости и его параметры: площадь, этаж, число комнат, тип дома, + состояние ремонта. Имя, паспортные данные и правоустанавливающие + документы на объект Оператором не запрашиваются.

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

    +

    + 3.3. Номер телефона обрабатывается Оператором только в том случае, + если пользователь самостоятельно принимает решение оставить заявку и + проставляет отдельную отметку о согласии на обработку персональных + данных в соответствии с Федеральным законом № 152-ФЗ. Без такой + отметки заявка не отправляется. Отметка о согласии по умолчанию не + проставлена — пользователь ставит её сам, осознанным действием. +

    +

    + 3.4. При оставлении заявки указанный пользователем номер телефона + сохраняется в привязке к конкретному адресу объекта, введённому этим + же пользователем. Именно с момента такой привязки совокупность данных + (телефон + адрес + факт обращения) рассматривается Оператором как + персональные данные пользователя, и на неё распространяются все + положения настоящей Политики в полном объёме. +

    +

    + 3.5. Дополнительно, при оплате Услуги на Сайте Оператор обрабатывает + номер телефона и/или адрес электронной почты, указанные пользователем + при оплате — для направления кассового чека в соответствии с + Федеральным законом от 22.05.2003 № 54-ФЗ, — а также технические + данные (IP-адрес, данные браузера и устройства), передаваемые в связи + с проведением платежа через платёжный сервис, подключённый Оператором.

    -

    Оператор

    - {LEGAL_ENTITY ? ( +

    4. Правовые основания обработки

    +

    + 4.1. Обработка номера телефона по заявке осуществляется на основании + отдельного согласия пользователя, выраженного проставлением отметки на + Сайте (п. 3.3), в соответствии со ст. 9 Федерального закона № 152-ФЗ. +

    +

    + 4.2. Обработка данных, указанных при оплате Услуги (п. 3.5), + осуществляется в целях исполнения договора, стороной которого является + пользователь (п. 5 ч. 1 ст. 6 Федерального закона № 152-ФЗ), а также + во исполнение обязанностей Оператора, установленных Федеральным + законом № 54-ФЗ. +

    + +

    5. Как обрабатываются данные при формировании расчёта

    +

    + 5.1. Введённый пользователем адрес объекта и его параметры (площадь, + этаж, число комнат, тип дома, состояние ремонта) передаются на сервер + Оператора и используются для подбора сопоставимых объектов + недвижимости — по сведениям о зарегистрированных сделках (в том числе + из открытых источников Росреестра), по объявлениям на партнёрских + площадках и, при недостатке иных данных, по оценочным моделям таких + площадок, используемым Оператором в качестве сверочных данных. +

    +

    + 5.2. Указанные в п. 5.1 данные об объекте сами по себе, в отрыве от + иных данных, не относятся к определённому физическому лицу и не + рассматриваются Оператором как персональные данные — в соответствии с + п. 3.2 настоящей Политики. +

    +

    + 5.3. На странице ввода адреса, доступной до момента отправки заявки + или оплаты, не используются счётчики и рекламные пиксели сторонних + сервисов; статические ресурсы страницы (шрифты и т. п.) подключаются с + домена Оператора. +

    + {PUBLIC_ESTIMATE_ENABLED ? null : (

    - {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, {LEGAL_ENTITY.address}. -

    - ) : ( -

    - Реквизиты оператора персональных данных и утверждённая политика - обработки будут опубликованы здесь до открытия публичного доступа к - сервису. До этого момента страница доступна не публично, а по прямой - ссылке. + 5.4. На сегодняшний день публичный расчёт по адресу на Сайте ещё не + открыт: форма адреса на главной странице работает только в браузере + пользователя — введённый адрес не передаётся на сервер Оператора и + нигде не сохраняется. Пункты 5.1–5.2 применяются с момента открытия + публичного расчёта.

    )} + +

    6. Сроки хранения и удаление данных

    +

    + 6.1. Если результат расчёта был оплачен пользователем, ссылка на отчёт + и связанные с ним данные хранятся Оператором в течение{" "} + {PAID_REPORT_RETENTION_MONTHS} месяцев с даты оплаты, после чего + подлежат удалению. На файл отчёта, ранее скачанный пользователем на + своё устройство, истечение этого срока не влияет — Оператор не + отзывает и не изменяет такой файл и не имеет к нему доступа после его + передачи пользователю. +

    +

    + 6.2. Для иных категорий персональных данных применяются сроки + хранения, необходимые для достижения целей обработки, указанных в + разделе 3, а также сроки, установленные законодательством РФ (в том + числе налоговым законодательством и законодательством о бухгалтерском + учёте). +

    +

    + 6.3. По истечении сроков хранения персональные данные подлежат + удалению или обезличиванию. +

    + +

    7. Отзыв согласия и удаление данных по обращению

    +

    + 7.1. Пользователь вправе в любой момент отозвать согласие на обработку + персональных данных, направив обращение в поддержку Оператора по + контактам, указанным в разделе 9 настоящей Политики. С момента + получения обращения Оператор прекращает использование номера телефона + пользователя для связи по соответствующей заявке. +

    +

    + 7.2. Обращения об отзыве согласия и об удалении данных рассматриваются + Оператором вручную. При удалении данных по обращению пользователя + Оператор физически удаляет телефон, адрес объекта и результат расчёта + из своих систем, а не ограничивается пометкой записи как неактивной. +

    +

    + 7.3. Помимо права на обращение, пользователь вправе получать + информацию об обработке своих персональных данных, требовать уточнения, + блокирования или уничтожения персональных данных, если они являются + неполными, устаревшими, неточными, незаконно полученными или не + являются необходимыми для заявленной цели обработки — в соответствии + со ст. 14 Федерального закона № 152-ФЗ. +

    + +

    8. Меры по защите персональных данных

    +

    + 8.1. Оператор применяет правовые, организационные и технические меры + для защиты персональных данных от неправомерного или случайного + доступа, уничтожения, изменения, блокирования, копирования, + распространения и иных неправомерных действий третьих лиц, в + соответствии со ст. 19 Федерального закона № 152-ФЗ. +

    + +

    + 9. Контакты Оператора по вопросам обработки персональных данных +

    +

    + E-mail:{" "} + + {SUPPORT_EMAIL} + +

    + {telegramHref ? ( +

    + Telegram:{" "} + + {SUPPORT_TELEGRAM_LABEL} + +

    + ) : null}
); diff --git a/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx b/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx new file mode 100644 index 00000000..c541b29c --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx @@ -0,0 +1,214 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { + LEGAL_DOCS_REVISION, + LEGAL_ENTITY, + OFFER_PATH, + PUBLIC_OFFER_URL, + PUBLIC_REFUND_URL, + SERVICE_PRICE_RUB, + SUPPORT_EMAIL, +} from "../content"; +import styles from "../landing.module.css"; + +/** + * Политика возврата денежных средств. + * + * ЗАЧЕМ ОТДЕЛЬНЫМ ДОКУМЕНТОМ, если раздел 8 оферты говорит то же самое. + * Порядок возврата — то, что модерация эквайера проверяет последним и отдельным + * пунктом; кроме того, у документа своя аудитория: человек, у которого уже + * списались деньги, читает не договор целиком, а страницу «как вернуть». + * Приоритет при расхождении отдан оферте — это записано и здесь (п. 1.2), и в + * самой оферте (п. 8.0), чтобы дублирование не создало второй источник истины. + * + * ОТКУДА ТЕКСТ. Редакция владельца от 13.08.2026 (docx), перенесённая + * дословно; плейсхолдер `[адрес электронной почты]` заменён на `SUPPORT_EMAIL`, + * цена и реквизиты — из `content.ts`. + * + * ЧТО ЗДЕСЬ ОПИСАНО РАНЬШЕ КОДА. Разделы 2.1 и 4 обещают АВТОМАТИЧЕСКИЙ возврат + * при техническом сбое — платёжного контура в репозитории ещё нет, значит на + * момент публикации это установленный порядок, а не наблюдаемое поведение + * системы. Так и задумано (документ публикуется до подключения эквайринга — + * без него заявку не примут), но при включении оплаты автоматический возврат + * обязан появиться в коде вместе с кнопкой, а не после неё: обещание уже + * опубликовано и является условием договора. + */ + +export const metadata: Metadata = { + title: "Политика возврата денежных средств — МЕРА", + description: + "Условия, порядок и сроки возврата денежных средств за расчёт сервиса «МЕРА».", + alternates: { canonical: PUBLIC_REFUND_URL }, +}; + +export default function MeraPublicRefundPage() { + const entity = LEGAL_ENTITY; + + return ( +
+
+ + ← На главную + + +

Политика возврата денежных средств

+

+ сервис «МЕРА» (meraocenka.ru). Редакция от {LEGAL_DOCS_REVISION} +

+ +

1. Общие положения

+

+ 1.1. Настоящая Политика определяет условия, порядок и сроки возврата + денежных средств, уплаченных Заказчиком за услугу сервиса «МЕРА» на + сайте meraocenka.ru (далее — «Услуга»), стоимость которой составляет{" "} + {SERVICE_PRICE_RUB} (сто пятьдесят) рублей за один расчёт. +

+

+ 1.2. Настоящая Политика размещена в свободном доступе в сети Интернет + по адресу {PUBLIC_REFUND_URL} и является неотъемлемой частью{" "} + + Публичной оферты + + , размещённой по адресу {PUBLIC_OFFER_URL}. Оферта и настоящая + Политика применяются совместно; в случае противоречий между ними + приоритет имеет Оферта. +

+

+ 1.3. Услуга считается оказанной с момента отображения результата + расчёта Заказчику на сайте либо направления его на указанную + Заказчиком электронную почту — в зависимости от того, что наступит + раньше. После этого момента оплата по общему правилу возврату не + подлежит, за исключением случаев, перечисленных в разделе 3 настоящей + Политики. +

+ +

2. Кто принимает решение о возврате

+

+ 2.1. Решения о возврате денежных средств по основаниям, не требующим + обращения Заказчика (технический сбой — раздел 4), принимаются + автоматически системой сайта без участия сотрудников Исполнителя. +

+

+ 2.2. Решения о возврате денежных средств по обращениям Заказчика (п. + 3.3–3.4) рассматриваются сотрудником службы поддержки Исполнителя, + ответственным за обработку почтового ящика{" "} + + {SUPPORT_EMAIL} + + . Итоговое решение по спорным или нестандартным случаям принимает + директор {entity ? entity.shortName : "Исполнителя"} либо + уполномоченное им лицо. +

+ +

3. Условия возврата

+

3.1. Возврат не производится, если:

+
    +
  • + Услуга оказана надлежащим образом — результат расчёта был + сформирован и предоставлен Заказчику; +
  • +
  • + основанием для обращения является несогласие Заказчика с полученным + числовым значением расчёта (расчёт носит индикативный характер, что + не является основанием для возврата). +
  • +
+

3.2. Возврат производится в полном объёме в следующих случаях:

+
    +
  • + технический сбой на стороне сервиса — расчёт не был предоставлен + Заказчику после оплаты (подробно — раздел 4); +
  • +
  • + Заказчик отказался от Услуги до момента её фактического оказания (то + есть до появления результата на экране или в почте); +
  • +
  • + денежные средства были списаны повторно из-за технической ошибки + платёжной формы; +
  • +
  • + оплата была произведена без согласия владельца карты — при + подтверждении этого факта банком-эмитентом. +
  • +
+

+ 3.3. Для случаев, указанных в п. 3.2 (кроме технического сбоя, который + обрабатывается автоматически), Заказчик направляет обращение на{" "} + + {SUPPORT_EMAIL} + {" "} + с указанием даты и примерного времени оплаты, суммы и последних 4 цифр + карты. +

+

+ 3.4. Срок рассмотрения обращения и возврата — не позднее 10 (десяти) + рабочих дней с момента получения обращения Исполнителем. +

+ +

4. Отдельный случай: оплата прошла, а расчёт не сформирован

+

+ 4.1. Если после успешной оплаты сервис по техническим причинам не смог + сформировать результат расчёта (сбой, ошибка обработки данных, + недоступность сервиса) в течение 15 (пятнадцати) минут с момента + оплаты, Услуга считается неоказанной. +

+

+ 4.2. Возврат в этом случае производится автоматически, в полном + объёме, без необходимости обращения Заказчика с заявлением или + претензией. +

+

+ 4.3. Срок такого автоматического возврата — не позднее 3 (трёх) + рабочих дней с момента фиксации сбоя системой. +

+

+ 4.4. Если по истечении указанного срока возврат не поступил, Заказчик + вправе обратиться на{" "} + + {SUPPORT_EMAIL} + {" "} + — такое обращение рассматривается вне очереди. +

+ +

5. Способ возврата

+

+ 5.1. Возврат денежных средств во всех случаях производится тем же + способом, которым была произведена оплата (на ту же банковскую карту), + если иной способ не согласован с Заказчиком дополнительно. +

+

+ 5.2. Срок зачисления денежных средств на счёт Заказчика после инициации + возврата зависит от банка-эмитента карты Заказчика и обычно составляет + от 1 до 30 календарных дней — это правило платёжных систем, а не + Исполнителя. +

+ +

6. Права потребителя

+

+ 6.1. Настоящая Политика не ограничивает права Заказчика — физического + лица, установленные Законом РФ от 07.02.1992 № 2300-1 «О защите прав + потребителей». В части, где условия настоящей Политики противоречили бы + указанному Закону, применяются положения Закона. +

+ +

7. Контакты по вопросам возврата

+

+ E-mail:{" "} + + {SUPPORT_EMAIL} + +

+ {entity ? ( +

+ {entity.shortName}, ИНН {entity.inn}, ОГРН {entity.ogrn} +

+ ) : null} +
+
+ ); +} From 597429cff97c57f900e99a1cbb510ff24988fb3c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 17:30:30 +0000 Subject: [PATCH 21/60] =?UTF-8?q?fix(ptica):=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20=D1=81=D0=BB=D0=BE=D1=8F=20=D0=9D=D0=A1=D0=9F=D0=94=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B3=D0=BB=D1=8F=D0=B4=D0=B8=D1=82=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=C2=AB=D0=B7=D0=B4=D0=B5=D1=81=D1=8C=20=D0=BD=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D0=BD=D0=B5=D1=82=C2=BB=20(#2464-G/H)=20(?= =?UTF-8?q?#2873)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/scrapers/nspd_client.py | 91 +++++++++++++++++-- backend/tests/scrapers/test_nspd_grid_walk.py | 64 +++++++++++++ 2 files changed, 147 insertions(+), 8 deletions(-) diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index 9742dd43..95a9019b 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -559,7 +559,11 @@ class NSPDClient: """ # Импортируем здесь чтобы избежать circular import: # nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain) - from app.scrapers.nspd_bulk_client import NSPDBulkClient + from app.scrapers.nspd_bulk_client import ( + NSPDBulkClient, + NspdBulkServerError, + NspdBulkWafError, + ) xmin, ymin, xmax, ymax = bbox width_m = xmax - xmin @@ -607,10 +611,45 @@ class NSPDClient: results = await asyncio.gather(*tasks, return_exceptions=True) features: list[NSPDFeature] = [] - for r in results: - if isinstance(r, Exception): - logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r) + # #2464-G: раньше ЛЮБОЕ исключение ячейки глушилось warning'ом и обход + # возвращал []. Отказ слоя (WAF-бан IP, 5xx на всех ячейках) становился + # неотличим от честного «здесь зон нет» — на проде это 124 дампа из 669 + # с territorial_zones_count=0, из них у 50 legacy-слой данные нашёл. + # Ниже — зеркало уже исправленного близнеца + # nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror). + server_errors = 0 + ok_cells = 0 + first_server_error: NspdBulkServerError | None = None + for idx, r in enumerate(results): + if isinstance(r, NspdBulkWafError): + # 403 WAF — бан IP. Пробрасываем немедленно: продолжать обход + # бессмысленно, а пустой результат соврал бы про отсутствие зон. + logger.warning( + "get_features_in_bbox_grid layer=%d cell=%d WAF 403 — прерываем обход: %s", + layer_id, + idx, + r, + ) + raise r + if isinstance(r, NspdBulkServerError): + server_errors += 1 + if first_server_error is None: + first_server_error = r + logger.debug( + "get_features_in_bbox_grid layer=%d cell=%d server error: %s", + layer_id, + idx, + r, + ) continue + if isinstance(r, Exception): + # Сетевые / parse-ошибки одной ячейки: обход не валим и НЕ + # считаем server-side, иначе сеть ложно поднимет layer_failed. + logger.warning( + "get_features_in_bbox_grid layer=%d cell=%d error: %s", layer_id, idx, r + ) + continue + ok_cells += 1 for bulk_feat in r: raw = { "id": bulk_feat.id, @@ -618,6 +657,20 @@ class NSPDClient: "properties": bulk_feat.properties, } features.append(NSPDFeature.from_raw(raw)) + + # Были server-side отказы И ни одна ячейка не прошла — лёг слой или + # весь NSPD. Возврат [] здесь означал бы «зон нет», хотя мы просто + # ничего не узнали. Пробрасываем, чтобы caller отличил одно от другого. + if server_errors > 0 and ok_cells == 0 and first_server_error is not None: + logger.warning( + "get_features_in_bbox_grid layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный " + "(%d server errors, 0 успешных ячеек) — бросаем вместо ложного пустого", + layer_id, + effective_n, + effective_n, + server_errors, + ) + raise first_server_error return features raw_features = asyncio.run(_run_grid()) @@ -679,6 +732,10 @@ class NSPDClient: dict[layerId, list[NSPDFeature]]. Ключи — все запрошенные layerId (пустой list если слой пуст / упал). Стабильная форма для caller'а. """ + # Локальный импорт по той же причине, что в get_features_in_bbox_grid: + # nspd_client ← nspd_bulk_client дало бы circular import на top-level. + from app.scrapers.nspd_bulk_client import NspdBulkServerError + layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys()) result: dict[int, list[NSPDFeature]] = {} for layer_id in layer_ids: @@ -686,7 +743,15 @@ class NSPDClient: feats = self.get_features_in_bbox_grid( layer_id, bbox_3857, grid_n=grid_n, step_m=step_m ) - except (NspdLiteError, NspdLiteWafError) as exc: + except (NspdLiteError, NspdLiteWafError, NspdBulkServerError) as exc: + # #2464-G: с этой правки grid-walk умеет бросать NspdBulkServerError + # («слой лёг целиком»). Здесь ловим его И оставляем прежнее поведение — + # пустой список на слой, — потому что именно это обещает докстрока + # («пустой list если слой пуст / упал») и на это опирается вызывающий. + # NspdBulkWafError НЕ ловим намеренно: 403 — это бан IP, продолжать + # обход остальных слоёв значит углублять бан. + # Ограничение честно: наружу отсюда «упал» и «пусто» по-прежнему + # неразличимы — у функции нет канала для флага. Отдельным заходом. logger.warning( "get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем", layer_id, @@ -840,9 +905,19 @@ class NSPDClient: `layers_fetched` в этом случае содержит только `('search',)`. Raises: - NspdLiteWafError при 403/429 на любом из layer запросов — caller - должен делать backoff. Partial-success НЕ возвращается; вся - операция атомарна (failure → exception). + NspdLiteWafError при 403/429 на legacy-запросах (parcels/buildings) — + caller должен делать backoff. + NspdBulkWafError при 403 на любой ячейке grid-walk-слоя (#2464-G) — + бан IP, обход прерывается сразу. + NspdBulkServerError когда grid-walk-слой сбойный ЦЕЛИКОМ (были 5xx и + ни одна ячейка не прошла) — иначе вернулся бы пустой список, + неотличимый от честного «здесь ничего нет». + + До #2464-G это место обещало атомарность, которой не было: grid-walk + глушил любое исключение ячейки и отдавал []. Теперь обещание верно + для отказа слоя и бана, но partial-success внутри слоя ВОЗМОЖЕН: + если часть ячеек упала по сети, а часть прошла, вернётся то, что + собралось, с warning'ом в лог на каждую упавшую ячейку. Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors, E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity. diff --git a/backend/tests/scrapers/test_nspd_grid_walk.py b/backend/tests/scrapers/test_nspd_grid_walk.py index 377ba20c..26d8e625 100644 --- a/backend/tests/scrapers/test_nspd_grid_walk.py +++ b/backend/tests/scrapers/test_nspd_grid_walk.py @@ -190,6 +190,70 @@ class TestGetFeaturesInBboxGrid: # 4 cells: 1 error + 3 good_feat → 1 unique feature assert any(f.feature_id == "feat-ok" for f in result) + # ── #2464-G: отказ слоя больше не маскируется пустым результатом ────────── + + def _grid(self, side_effect: Any, *, grid_n: int = 2) -> list[NSPDFeature]: + """Прогнать grid-walk с подменённым wms_feature_info.""" + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=side_effect) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + return NSPDClient().get_features_in_bbox_grid( + 36328, self.BBOX, grid_n=grid_n, step_m=1.0 + ) + + def test_waf_403_aborts_grid_instead_of_empty_result(self) -> None: + """403 WAF на ячейке — бан IP, обход прерывается. + + До #2464-G исключение глушилось и метод отдавал [] — «зон здесь нет», + неотличимое от честного пустого слоя. На проде это 124 дампа из 669 + с territorial_zones_count=0, у 50 из которых соседний legacy-слой + данные всё-таки нашёл. + """ + from app.scrapers.nspd_bulk_client import NspdBulkWafError + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + raise NspdBulkWafError("HTTP 403 WAF") + + with pytest.raises(NspdBulkWafError): + self._grid(_wms) + + def test_all_cells_5xx_raises_instead_of_empty_result(self) -> None: + """Все ячейки упали с 5xx — слой лёг целиком, а не «пуст».""" + from app.scrapers.nspd_bulk_client import NspdBulkServerError + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + raise NspdBulkServerError("HTTP 500 ServiceException") + + with pytest.raises(NspdBulkServerError): + self._grid(_wms) + + def test_partial_5xx_keeps_data_and_does_not_raise(self) -> None: + """Часть ячеек 5xx, часть прошла — отдаём собранное, не бросаем. + + Контроль к двум тестам выше: правка НЕ превращает любую ошибку в отказ. + Именно этот тест ловил бы обратную крайность — «чуть что, роняем обход». + """ + from app.scrapers.nspd_bulk_client import NspdBulkServerError + + good_feat = _make_bulk_feature("feat-ok", {"cad_num": "66:41:001:1"}) + call_n: list[int] = [0] + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + call_n[0] += 1 + if call_n[0] <= 2: + raise NspdBulkServerError("HTTP 500 ServiceException") + return [good_feat] + + result = self._grid(_wms) + assert any( + f.feature_id == "feat-ok" for f in result + ), "успешные ячейки должны попасть в результат, даже если часть слоя упала" + def test_returns_nspd_feature_instances(self) -> None: """Метод возвращает list[NSPDFeature] а не NSPDBulkFeature.""" bulk_feat = _make_bulk_feature("feat-xyz", {"cad_num": "66:41:001:1"}) From 7de5aa05518b26bebf65230ada9c5c67273f32d1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 17:32:07 +0000 Subject: [PATCH 22/60] =?UTF-8?q?ci:=20=D0=BF=D0=B5=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D0=B4=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=82=D0=B0=20pytest=20=D1=8F=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=20(#2871)=20(#2875)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index a9cb7fa7..1bcf6635 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -233,11 +233,18 @@ jobs: # coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge). # term-missing → видно непокрытые строки прямо в job-логе. run: | + # #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит + # в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как + # «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется: + # ниже `exit $rc`. + rc=0 uv run pytest -q -rs --ignore=tests/smoke \ --cov=app \ --cov-report=term-missing:skip-covered \ --cov-report=xml:coverage.xml \ - --cov-fail-under=65 + --cov-fail-under=65 || rc=$? + echo "### pytest вернул код $rc" + exit $rc - name: Coverage summary → job output # Дешёвый human-readable итог. Бежит даже если gate упал (if: always) — From c40261cb16fca15f58281527a3d9aa875fcdd017 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 17:46:27 +0000 Subject: [PATCH 23/60] =?UTF-8?q?fix(ptica):=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=B0=20=D0=B2=20?= =?UTF-8?q?velocity=20=D1=81=D1=81=D1=8B=D0=BB=D0=B0=D0=BB=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B0=D0=BB=D0=B8=D0=B0=D1=81,=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BE=D0=B3=D0=BE=20=D0=BD=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=20CTE=20(#2464-G)=20(#2865)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.gitignore | 2 + backend/app/services/site_finder/velocity.py | 96 +++++++++++-------- .../integration/test_analyze_parcels_sql.py | 43 ++++++++- backend/tests/skip_allowlist.txt | 1 + 4 files changed, 97 insertions(+), 45 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index 6350e986..b073c123 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,3 @@ .coverage +# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит. +coverage.xml diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index a13585fb..644ebe1f 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -36,6 +36,48 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) +# Конкуренты в радиусе — модульная константа (а не inline f-string), чтобы +# integration-тест мог прогнать EXPLAIN по обеим подстановкам `{class_filter}`. +# Ветка с фильтром до #2464-G не парсилась вообще: ссылалась на алиас `o`, +# которого внутри CTE нет (`missing FROM-clause entry for table "o"`). +_COMPETITORS_SQL_TMPL = """ + WITH latest_obj AS ( + SELECT DISTINCT ON (obj_id) + obj_id, + comm_name, + dev_name, + -- #38: эффективный класс — реальный, иначе fallback + COALESCE(obj_class, obj_class_fallback) AS obj_class, + latitude, + longitude, + district_name + FROM domrf_kn_objects + WHERE latitude IS NOT NULL + AND longitude IS NOT NULL + AND region_cd = 66 + {class_filter} + ORDER BY obj_id, snapshot_date DESC NULLS LAST + ) + SELECT + o.obj_id, + o.comm_name, + o.dev_name, + o.obj_class, + o.district_name, + ST_Distance( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography + ) AS distance_m + FROM latest_obj o + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT 200 +""" + # Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median). # Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на # один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка: @@ -173,9 +215,17 @@ def compute_velocity( # только если явно передан. #38: при NULL реального класса используем # obj_class_fallback (yandex_match / price_inference) — реальный obj_class # в приоритете (COALESCE), поведение для размеченных ЖК не меняется. - class_filter = ( - "AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else "" - ) + # Колонки БЕЗ алиаса: фильтр подставляется ВНУТРЬ latest_obj, где FROM — + # голый domrf_kn_objects. Алиас `o` появляется только во внешнем SELECT, + # и `o.obj_class` здесь давал `missing FROM-clause entry for table "o"` + # (#2464-G, прод-EXPLAIN 13.08). Ошибку глотал except ниже → velocity + # молча выпадал из отчёта. Не срабатывало только потому, что единственный + # вызывающий (parcels.py) obj_class не передаёт. + # NB для первого, кто ветку включит: сравнение точное и регистрозависимое, а + # в проде классы с большой буквы и словарь шире ожидаемого — «Комфорт» 870, + # «Типовой» 224, «Бизнес» 95, «Премиум» 13, «Элит» 12, «Стандарт» 9, + # «Элитный» 4 объекта (замер 13.08). Передавать нужно ровно эти строки. + class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :obj_class" if obj_class else "" # SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx. # db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction # (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup). @@ -183,45 +233,7 @@ def compute_velocity( with db.begin_nested(): comp_rows = ( db.execute( - text( - f""" - WITH latest_obj AS ( - SELECT DISTINCT ON (obj_id) - obj_id, - comm_name, - dev_name, - -- #38: эффективный класс — реальный, иначе fallback - COALESCE(obj_class, obj_class_fallback) AS obj_class, - latitude, - longitude, - district_name - FROM domrf_kn_objects - WHERE latitude IS NOT NULL - AND longitude IS NOT NULL - AND region_cd = 66 - {class_filter} - ORDER BY obj_id, snapshot_date DESC NULLS LAST - ) - SELECT - o.obj_id, - o.comm_name, - o.dev_name, - o.obj_class, - o.district_name, - ST_Distance( - ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, - ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography - ) AS distance_m - FROM latest_obj o - WHERE ST_DWithin( - ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, - ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography, - :radius_m - ) - ORDER BY distance_m ASC - LIMIT 200 - """ - ), + text(_COMPETITORS_SQL_TMPL.format(class_filter=class_filter)), { "parcel_wkt": parcel_geom_wkt, "radius_m": radius_km * 1000.0, diff --git a/backend/tests/integration/test_analyze_parcels_sql.py b/backend/tests/integration/test_analyze_parcels_sql.py index 3b2e8fff..b50d0577 100644 --- a/backend/tests/integration/test_analyze_parcels_sql.py +++ b/backend/tests/integration/test_analyze_parcels_sql.py @@ -39,6 +39,7 @@ from sqlalchemy.orm import Session from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL +from app.services.site_finder.velocity import _COMPETITORS_SQL_TMPL from tests.integration.conftest import requires_test_db # NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time @@ -103,9 +104,9 @@ class TestNeighborsSummarySql: for kw in forbidden_aliases: # ищем паттерн ``WITH AS (`` или ``, AS (`` — оба # формы CTE-биндинга. - assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, ( - f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)" - ) + assert ( + f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql + ), f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)" # ── parcel_ird_overlaps SQL ────────────────────────────────────────────────── @@ -167,3 +168,39 @@ class TestPsycopg3CastAntipattern: f"{name} содержит psycopg v3 antipattern: {matches}. " f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md." ) + + +# ── velocity: конкуренты в радиусе (#2464-G) ───────────────────────────────── + + +class TestVelocityCompetitorsSql: + """``_COMPETITORS_SQL_TMPL`` из ``app.services.site_finder.velocity``. + + Шаблон подставляется в двух видах, и **вторая подстановка до #2464-G + не парсилась вообще**: фильтр класса ссылался на алиас ``o``, который + существует только во внешнем SELECT, а подставляется фильтр ВНУТРЬ CTE + ``latest_obj`` (FROM domrf_kn_objects, без алиаса) → + ``missing FROM-clause entry for table "o"`` (прод-EXPLAIN 13.08). + + Почему это не падало в проде: единственный вызывающий + (``analyze_parcel``) ``obj_class`` не передаёт → ветка мёртвая. + Падало бы молча — исключение глотает ``except`` в ``compute_velocity``, + и блок velocity просто исчезал бы из отчёта с одной строкой в логе. + + Тест закрывает обе ветки, а не только ту, что сегодня исполняется. + """ + + @requires_test_db + @pytest.mark.integration + @pytest.mark.parametrize( + "class_filter", + ["", "AND COALESCE(obj_class, obj_class_fallback) = :obj_class"], + ids=["no_class_filter", "with_class_filter"], + ) + def test_explain_competitors(self, phantom_check_session: Session, class_filter: str) -> None: + """Обе подстановки шаблона парсятся и планируются против реальной схемы.""" + _explain_text( + phantom_check_session, + _COMPETITORS_SQL_TMPL.format(class_filter=class_filter), + {"parcel_wkt": _EKB_WKT, "radius_m": 3000.0, "obj_class": "комфорт"}, + ) diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index 02ca21bc..470eef48 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -34,6 +34,7 @@ tests/test_layout_tz_pdf.py # (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py. # ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**. tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap +tests/integration/test_analyze_parcels_sql.py::TestVelocityCompetitorsSql::test_explain_competitors tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query From a4d6cbba253c421004f6f46eccd52feafa1648f3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 13 Aug 2026 17:47:01 +0000 Subject: [PATCH 24/60] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=8E=20=D0=B1=D0=B0=D0=BD=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B5=20egress-?= =?UTF-8?q?=D1=83=D0=B7=D0=BB=D0=B0=20(#2877)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/proxy_egress.py | 73 +++++-- .../tests/services/test_proxy_egress.py | 184 +++++++++++++++++- 2 files changed, 229 insertions(+), 28 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_egress.py b/tradein-mvp/backend/app/services/proxy_egress.py index c28426a4..8227a40a 100644 --- a/tradein-mvp/backend/app/services/proxy_egress.py +++ b/tradein-mvp/backend/app/services/proxy_egress.py @@ -19,10 +19,19 @@ reap_stale_leases) — она рассчитана на долгоживущие и без мутаций. ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS -(тот же карантинный порог, что у acquire), нет активной строки в -scrape_proxy_source_bans для ЭТОГО source. Среди кандидатов — меньший consecutive_fails, -при равенстве — более свежий last_ok_at (NULLS LAST). Не изобретаем ротацию/балансировку: -это резолвер «дай рабочий прокси прямо сейчас», не lease-менеджер. +(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now()) +в scrape_proxy_source_bans для ЭТОГО source — это по-прежнему жёсткий фильтр, не +влияющий на порядок. Порядок среди прошедших фильтр (замер 2026-08-10, #2825 доп.): +сначала узлы БЕЗ ИСТОРИИ банов по этому source, затем по возрастанию ban_count — +даже если сама строка бана истекла (banned_until <= now()), её ban_count всё равно +учитывается, ведь строка НЕ удаляется сразу (purge только через 7 суток чистой +работы, см. 210-я миграция) и остаётся памятью «этот узел здесь уже банился N раз». +Внутри равного ban_count — прежние критерии без изменений: меньший consecutive_fails, +при равенстве — более свежий last_ok_at (NULLS LAST). Так хронически банящийся узел +(здоров по health-check, но регулярно ловит 403 от конкретной площадки) не всплывает +первым сразу после истечения TTL — свежий healthcheck сам по себе больше не решает. +Не изобретаем ротацию/балансировку: это резолвер «дай рабочий прокси прямо сейчас», +не lease-менеджер. FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный `SCRAPER_PROXY_URL` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет": @@ -39,7 +48,9 @@ FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review env в обход учёта банов. НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url -несёт логин/пароль, в лог никогда не идёт целиком) и id узла; при legit-fallback — +несёт логин/пароль, в лог никогда не идёт целиком), id узла и ban_count по этому +source (0, если истории нет) — чтобы по логу было видно, что узел с историей банов +выбран осознанно (пул исчерпан по чистым узлам), а не тихо; при legit-fallback — warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы их нельзя было спутать в логах/алертах. @@ -98,6 +109,10 @@ class _Candidate: id: int url: str label: str | None + ban_count: int + """ban_count по scrape_proxy_source_bans ДЛЯ ЭТОГО source (0, если строки нет — + узел ни разу не банился этой площадкой). Учитывает и истёкшие строки бана + (banned_until <= now(), но ещё не спурженные) — см. докстринг модуля.""" def _safe_label(proxy_id: int, label: str | None, url: str) -> str: @@ -113,23 +128,31 @@ def _safe_label(proxy_id: int, label: str | None, url: str) -> str: def _pick_candidate(db: Session, source: str) -> _Candidate | None: - """READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел.""" + """READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел. + + LEFT JOIN (не EXISTS) на scrape_proxy_source_bans — нужен сам ban_count для + ранжирования, а не только факт активного бана. Активный бан (banned_until > now()) + по-прежнему полный фильтр в WHERE, это НЕ меняется; но истёкшая (и ещё не + спурженная) строка бана остаётся в ORDER BY как история — см. докстринг модуля. + COALESCE(b.ban_count, 0) — узел без единой строки истории по source ранжируется + как ban_count=0, естественно раньше любого узла с реальной историей банов. + """ row = ( db.execute( text( """ - SELECT id, url, label - FROM scrape_proxies - WHERE enabled - AND consecutive_fails < CAST(:max_fails AS integer) - AND NOT EXISTS ( - SELECT 1 - FROM scrape_proxy_source_bans b - WHERE b.proxy_id = scrape_proxies.id - AND b.source = CAST(:source AS text) - AND b.banned_until > now() - ) - ORDER BY consecutive_fails ASC, last_ok_at DESC NULLS LAST, id + SELECT sp.id, sp.url, sp.label, COALESCE(b.ban_count, 0) AS ban_count + FROM scrape_proxies AS sp + LEFT JOIN scrape_proxy_source_bans AS b + ON b.proxy_id = sp.id + AND b.source = CAST(:source AS text) + WHERE sp.enabled + AND sp.consecutive_fails < CAST(:max_fails AS integer) + AND (b.banned_until IS NULL OR b.banned_until <= now()) + ORDER BY COALESCE(b.ban_count, 0) ASC, + sp.consecutive_fails ASC, + sp.last_ok_at DESC NULLS LAST, + sp.id LIMIT 1 """ ), @@ -143,7 +166,16 @@ def _pick_candidate(db: Session, source: str) -> _Candidate | None: # середине более широкой операции). if row is None: return None - return _Candidate(id=int(row["id"]), url=str(row["url"]), label=row["label"]) + return _Candidate( + id=int(row["id"]), + url=str(row["url"]), + label=row["label"], + # .get(..., 0) — не .__getitem__: production-SELECT ВСЕГДА проецирует + # ban_count (см. запрос выше), но нулевой default защищает от полного KeyError + # у сторонних fake-db в других test-модулях (напр. test_2830_pool_bypass_tails), + # которые мокают этот же db.execute() урезанным dict без нового столбца. + ban_count=int(row.get("ban_count", 0)), + ) @dataclass(frozen=True) @@ -239,10 +271,11 @@ def resolve_proxy_url(db: Session, source: str) -> str | None: if candidate is not None: logger.info( - "proxy_egress: source=%s -> pool proxy id=%d (%s)", + "proxy_egress: source=%s -> pool proxy id=%d (%s) ban_count=%d", source, candidate.id, _safe_label(candidate.id, candidate.label, candidate.url), + candidate.ban_count, ) return candidate.url diff --git a/tradein-mvp/backend/tests/services/test_proxy_egress.py b/tradein-mvp/backend/tests/services/test_proxy_egress.py index cf303b2b..b123bb9c 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_egress.py +++ b/tradein-mvp/backend/tests/services/test_proxy_egress.py @@ -1,4 +1,5 @@ -"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616). +"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616, +ban-history ранжирование доп. #2825 от 2026-08-13). Покрытие БЕЗ live-сети/БД: FakeSession эмулирует ДВА запроса над scrape_proxies + scrape_proxy_source_bans — основной SELECT кандидата (`_pick_candidate`) и, только @@ -6,11 +7,16 @@ scrape_proxy_source_bans — основной SELECT кандидата (`_pick_ "пул пуст" от "пул не пуст, все отсеяны". - выбирается небанненный прокси; - - забаненный ДЛЯ ИСТОЧНИКА не выбирается; + - забаненный ДЛЯ ИСТОЧНИКА (АКТИВНО, banned_until > now()) не выбирается; - забаненный для ДРУГОГО источника — выбирается (суть #2600 п.2: Авито банит IP, - Яндекс через тот же IP ходит чисто); - - при нескольких кандидатах — меньший consecutive_fails выигрывает; - - при равном consecutive_fails — более свежий last_ok_at выигрывает; + Яндекс через тот же IP ходит чисто) — включая случай, когда у него накопилась + ИСТОРИЯ банов по другому source: на ранжирование ДЛЯ ТЕКУЩЕГО source это не влияет; + - узел с историей банов (даже истёкшей) по ЭТОМУ source уступает чистому узлу без + истории, даже когда у чистого узла хуже consecutive_fails/last_ok_at; + - при равной истории (ban_count) — работает прежний tie-break: меньший + consecutive_fails, затем более свежий last_ok_at; + - активный бан (banned_until > now()) по-прежнему полностью исключает узел, вне + зависимости от ban_count; - пул ПУСТ (0 строк вообще) → легитимный fallback на settings.scraper_proxy_url, logger.WARNING с текстом «пуст»; - пул пуст И SCRAPER_PROXY_URL не задан → None (прямое подключение), WARNING; @@ -59,6 +65,14 @@ class FakeSession: for b in self.bans ) + def _ban_count(self, pid: int, source: str) -> int: + """COALESCE(b.ban_count, 0) семантика LEFT JOIN — история учитывается ДАЖЕ + если сама строка бана уже истекла (banned_until <= now(), ещё не спурженная).""" + for b in self.bans: + if b["proxy_id"] == pid and b["source"] == source: + return int(b.get("ban_count", 1)) + return 0 + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) p = params or {} @@ -67,7 +81,7 @@ class FakeSession: max_fails = p["max_fails"] source = p["source"] - if "pool_total" in sql: # _diagnose_no_candidate aggregate + if "pool_total" in sql: # _diagnose_no_candidate aggregate (активные баны, без истории) unhealthy = sum( 1 for r in self.rows if not r["enabled"] or r["consecutive_fails"] >= max_fails ) @@ -88,7 +102,9 @@ class FakeSession: ] ) - # _pick_candidate primary SELECT + # _pick_candidate primary SELECT — LEFT JOIN на bans по source: активный бан + # по-прежнему исключает узел (WHERE), а ban_count (в т.ч. от истёкшего бана) + # ранжирует прошедших фильтр: сначала без истории (0), затем по возрастанию. cands = [ r for r in self.rows @@ -98,12 +114,15 @@ class FakeSession: ] cands.sort( key=lambda r: ( + self._ban_count(r["id"], source), r["consecutive_fails"], -(r["last_ok_at"] or datetime.min.replace(tzinfo=UTC)).timestamp(), r["id"], ) ) - return _FakeResult([dict(r) for r in cands[:1]]) + return _FakeResult( + [{**r, "ban_count": self._ban_count(r["id"], source)} for r in cands[:1]] + ) def _proxy( @@ -207,6 +226,155 @@ def test_tiebreak_fresher_last_ok_at_wins_on_equal_fails() -> None: assert result == "http://u:p@fresh.local:8080" +def test_clean_node_beats_node_with_expired_ban_history_for_source() -> None: + """Замер на проде 2026-08-10: asocks-residential-1 отдавал 403 и cian, и avito, но + после истечения TTL всплывал первым, потому что consecutive_fails=0 у ОБОИХ узлов + и решал только свежий healthcheck. История (ban_count) ДОЛЖНА перевешивать даже + когда у банившегося узла лучше consecutive_fails/last_ok_at.""" + now = datetime.now(UTC) + db = FakeSession( + [ + _proxy( + 1, + consecutive_fails=0, + last_ok_at=now, # свежее всех — раньше выиграл бы по старому правилу + url="http://u:p@chronic.local:8080", + ), + _proxy( + 2, + consecutive_fails=1, + last_ok_at=now - timedelta(hours=3), + url="http://u:p@clean.local:8080", + ), + ], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": now - timedelta(hours=1), # ИСТЁК, но ban_count остаётся + "ban_count": 4, + } + ], + ) + result = resolve_proxy_url(db, "avito") + assert result == "http://u:p@clean.local:8080" + + +def test_no_history_node_beats_node_with_ban_count_one() -> None: + """Узел БЕЗ ЕДИНОЙ строки истории (ban_count трактуется как 0) выигрывает у узла с + ban_count=1, даже при равном consecutive_fails/last_ok_at.""" + now = datetime.now(UTC) + db = FakeSession( + [ + _proxy(1, consecutive_fails=0, last_ok_at=now, url="http://u:p@once-banned.local:8080"), + _proxy( + 2, consecutive_fails=0, last_ok_at=now, url="http://u:p@never-banned.local:8080" + ), + ], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": now - timedelta(hours=2), + "ban_count": 1, + } + ], + ) + result = resolve_proxy_url(db, "cian") + assert result == "http://u:p@never-banned.local:8080" + + +def test_equal_ban_history_falls_back_to_prior_tiebreak() -> None: + """При РАВНОМ ban_count у обоих узлов -- прежний порядок tie-break (consecutive_fails, + затем last_ok_at) без изменений.""" + now = datetime.now(UTC) + db = FakeSession( + [ + _proxy( + 1, + consecutive_fails=2, + last_ok_at=now, + url="http://u:p@flaky-history.local:8080", + ), + _proxy( + 2, + consecutive_fails=0, + last_ok_at=now - timedelta(hours=1), + url="http://u:p@solid-history.local:8080", + ), + ], + bans=[ + { + "proxy_id": 1, + "source": "yandex", + "banned_until": now - timedelta(hours=5), + "ban_count": 2, + }, + { + "proxy_id": 2, + "source": "yandex", + "banned_until": now - timedelta(hours=5), + "ban_count": 2, + }, + ], + ) + result = resolve_proxy_url(db, "yandex") + # Равный ban_count=2 у обоих -- решает consecutive_fails (0 < 2). + assert result == "http://u:p@solid-history.local:8080" + + +def test_ban_history_on_other_source_does_not_affect_ranking() -> None: + """Высокий ban_count по source=cian у узла НЕ влияет на его ранжирование для + source=avito -- история строго per-source, ровно как активный бан (#2600 п.2).""" + now = datetime.now(UTC) + db = FakeSession( + [ + _proxy( + 1, + consecutive_fails=0, + last_ok_at=now, + url="http://u:p@cian-history-only.local:8080", + ), + _proxy( + 2, + consecutive_fails=0, + last_ok_at=now - timedelta(hours=2), + url="http://u:p@clean-everywhere.local:8080", + ), + ], + bans=[ + { + "proxy_id": 1, + "source": "cian", # ДРУГОЙ source, не avito + "banned_until": now - timedelta(hours=1), + "ban_count": 9, + } + ], + ) + result = resolve_proxy_url(db, "avito") + # Для avito у узла 1 ban_count=0 (истории по avito нет) -- выигрывает по last_ok_at. + assert result == "http://u:p@cian-history-only.local:8080" + + +def test_active_ban_still_excludes_regardless_of_ban_count() -> None: + """Активный бан по-прежнему полный фильтр -- ban_count=1 (низкий) не спасает узел + с АКТИВНЫМ баном от исключения.""" + now = datetime.now(UTC) + db = FakeSession( + [_proxy(1, url="http://u:p@actively-banned.local:8080")], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": now + timedelta(hours=6), + "ban_count": 1, + } + ], + ) + with pytest.raises(ProxyPoolExhaustedError): + resolve_proxy_url(db, "avito") + + def test_empty_pool_falls_back_to_env_with_warning( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 01b5e73ea4654c8c14267815bb906b54bd129e63 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 13 Aug 2026 19:08:44 +0000 Subject: [PATCH 25/60] =?UTF-8?q?feat(tradein):=20=D0=B2=D1=81=D1=8F=20?= =?UTF-8?q?=D0=A1=D0=B2=D0=B5=D1=80=D0=B4=D0=BB=D0=BE=D0=B2=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=E2=80=94=2040=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20city-sweep=20(#2879)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/tasks/avito_detail_backfill.py | 7 + ...chedules_seed_oblast_city_sweeps_wave2.sql | 951 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + tradein-mvp/backend/tests/test_city_sweep.py | 85 +- ..._migration_262_oblast_city_sweeps_wave2.py | 452 +++++++++ .../src/scraper_kit/orchestration/pipeline.py | 195 +++- 6 files changed, 1681 insertions(+), 10 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql create mode 100644 tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index a45af2d3..d572c1bd 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -109,9 +109,16 @@ __all__ = [ # матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ # матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный # слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан. +# +# #262 wave 2: avito_slug — Optional в CityLocation (не у каждого oblast-города +# подтверждён). Города без avito_slug пропускаем целиком — у них НЕТ avito_city_ +# sweep schedule (262_), значит НЕТ и avito-листингов с их URL; паттерн для них +# был бы либо мёртвым, либо (что хуже) построен из city_slug вместо реального +# avito URL-сегмента и создал бы ложный LIKE-матч. _OBLAST_AVITO_URL_PATTERNS = tuple( "%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%" for loc in CITY_LOCATIONS.values() + if loc.avito_slug is not None ) diff --git a/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql b/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql new file mode 100644 index 00000000..e03493d7 --- /dev/null +++ b/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql @@ -0,0 +1,951 @@ +-- 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql +-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — WAVE 2: +-- avito/cian/yandex city-sweep за пределами Екатеринбурга для оставшихся 40 городов +-- области (wave 1 — 179_scrape_schedules_seed_oblast_city_sweeps.sql, 5 городов: +-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/verkhnyaya_pyshma/serov). Объявления +-- по области сейчас 3229 против 20111 по ЕКБ — wave 2 заводит оставшийся охват +-- Свердловской обл. Domclick (BFF, city_id-based) — отдельный rollout, сюда НЕ входит. +-- +-- Координаты городов (lat/lon/название) — проверены на проде (геокодер + независимая +-- сверка медианой координат сделок Росреестра по городу, exclusion в радиусе 12км от +-- ЕКБ). CITY_ANCHORS-записи для всех 40 slug'ов — тот же PR, +-- packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py. +-- +-- БИСЕРТЬ ИСКЛЮЧЕНА ЦЕЛИКОМ (не 41, а 40 городов): у Циана её нет вообще — поиск на +-- любой запрос ("Бисерть", "пгт Бисерть") отдаёт Сысерть id=176028. Это пгт, а не +-- город области — не заводится ни в CITY_ANCHORS, ни здесь. +-- +-- ═══ ГЛАВНОЕ ОТЛИЧИЕ ОТ ПЕРВОЙ ВЕРСИИ ЭТОГО ФАЙЛА ═══ +-- Первая версия (до ревью) заводила 41 город × 3 источника = 123 строки для ВСЕХ +-- источников сразу, планируя добыть provider-идентификаторы (avito_slug/cian_region_id/ +-- yandex_rgid) ПОСЛЕ. Это оказалось бы РОВНО тем самым багом, о котором предупреждала её +-- же шапка: без подтверждённого идентификатора run_avito_city_sweep/run_yandex_city_sweep +-- падают на ЕКБ-дефолт (region_id/rgid Екатеринбурга) — развёртка "включена", но реально +-- собирает ЕКБ под меткой чужого города, порча данных под видом покрытия. +-- +-- Идентификаторы теперь ДОБЫТЫ И ВАЛИДИРОВАНЫ (см. CITY_LOCATIONS-коммент в pipeline.py: +-- cian_id — api.cian.ru/geo-suggest/v1/suggest; yandex_rgid — realty.yandex.ru/gate/ +-- region_suggest/suggest; avito_slug — живой GET avito.ru//kvartiry; все три метода +-- валидированы 5/5 на wave-1 городах с уже известными значениями). Но НЕ у каждого города +-- подтверждены ВСЕ ТРИ идентификатора. Правило этой миграции: **строка заводится ТОЛЬКО +-- там, где идентификатор подтверждён**. Развёртка, которая молча соберёт Екатеринбург, +-- хуже отсутствующей — недостающие источники НЕ заводим вовсе (а не заводим с заглушкой/ +-- fallback). +-- +-- Дополнительный defensive guard в коде (тот же PR, pipeline.py): если КОГДА-ЛИБО +-- run_avito_city_sweep/run_yandex_city_sweep будет вызван с city_slug, у которого в +-- CITY_LOCATIONS известный город, но конкретный provider-идентификатор всё ещё None — +-- функция явно падает `ValueError` (НЕ молчаливый ЕКБ-дефолт). При штатной эксплуатации +-- этой миграции (schedule заводится только при подтверждённом идентификаторе) этот +-- ValueError сработать не должен — он ловит будущий рассинхрон данных, не текущий. +-- +-- ИТОГО 102 строки (не 123): +-- cian_city_sweep_* — 40 строк (cian_region_id подтверждён у ВСЕХ 40 городов). +-- yandex_city_sweep_* — 39 строк (ВСЕ, КРОМЕ mikhaylovsk — Михайловск Нижнесергинского +-- р-на ОТСУТСТВУЕТ в гео-базе Яндекс.Недвижимости вообще: единственный "Михайловск" +-- там — ставропольский, rgid 586221, подставлять чужой регион нельзя. Это +-- подтверждённое ОТСУТСТВИЕ данных у источника, не "не проверили" — довести +-- нечем, ждать нечего). +-- avito_city_sweep_* — 23 строки. avito_slug НЕ подтверждён для 17 городов: +-- revda, polevskoy, berezovskiy, zarechny, kachkanar, sredneuralsk, degtyarsk, +-- artemovskiy, kamyshlov, sukhoy_log, kushva, karpinsk, nizhnyaya_tura, +-- nizhnie_sergi, lesnoy, verkhoturye, mikhaylovsk. +-- Причина по каждому — либо чистый 404 на опробованных вариантах slug'а (omonym- +-- коллизия с городом в другом регионе — нужна avito-специфичная дизамбигуация, +-- которой в проверке не делали), либо 403/429 из-за исчерпания пула прокси во +-- время проверки (кандидат НЕ опровергнут, просто НЕ подтверждён — это единственная +-- категория из трёх, которую стоит ПЕРЕПРОВЕРИТЬ на свежем пуле и добрать отдельной +-- миграцией; остальные — city_rgid mikhaylovsk и omonym-404 avito — подтверждённое +-- отсутствие/коллизия, довести нечем). +-- +-- !!! DORMANT BY DESIGN !!! Все 102 строки ship enabled = false. Оператор включает +-- ВРУЧНУЮ по одному городу за раз (как в wave 1), волнами после деплоя: +-- UPDATE scrape_schedules SET enabled = true WHERE source = 'cian_city_sweep_revda'; +-- Capability уже полностью wired — тот же механизм, что и wave 1 (pipeline.CITY_ANCHORS/ +-- get_city_anchors, scheduler._job_{avito,cian,yandex}_city_sweep читают +-- default_params->>'city', wildcard-registry "*_city_sweep_*" в +-- scraper_kit.orchestration.scheduler._default_kit_handlers) — код скраперов/хендлеров +-- НЕ меняется (кроме defensive-guard в pipeline.py выше, не меняющего штатный путь). +-- +-- default_params — за основу взяты прод-дефолты enabled-городов wave 1 (см. 179_ + +-- 206_), с тремя отличиями: +-- 1. radius_m = 3000 у avito/cian (было 1500 в 179_) — один anchor на город должен +-- покрыть город целиком; сама 179_ предупреждала, что 1500м мало для городов +-- крупнее одного круга. yandex — 25000 как есть (gate-API город скоупит city_rgid, +-- lat/lon/radius_m игнорирует целиком, см. run_yandex_city_sweep docstring — +-- radius_m там мёртвый default). +-- 2. detail_top_n = 0 у avito (было 20 в 179_) — Avito detail-страницы сейчас отдают +-- HTTP 439 firewall независимо от IP (issue #2827). Обречённые detail-запросы на +-- 23 подтверждённых города только приблизят бан общего прокси-пула зря — не тратим +-- их, пока #2827 не починен. cian detail_top_n = 10 — оставлен как в 179_. +-- 3. interval_days = 3 у всех трёх источников — тот же такт, на который migration 206_ +-- перевела wave-1 15 job'ов после замера (daily избыточен, независимая проверка по +-- listings_snapshots показала ~0.02-0.15%/сутки волатильности цены). +-- +-- window_start_hour/window_end_hour (UTC, 1-часовые окна): 24 часа в сутках, 102 новые +-- строки — полная уникальность окна на строку математически невозможна для cian/yandex +-- (40 и 39 > 16-18 свободных часов), возможна для avito (23 <= 18). Тот же round-robin +-- scheme, что в первой версии файла (координаты НЕ пересчитывались — просто отфильтрован +-- набор строк по подтверждённым идентификаторам, часы у оставшихся ГОРОДОВ не менялись): +-- окна исключают ПОЛНОСТЬЮ (а) EKB-окна (avito 6-7, cian 2-5, yandex 16-17) и (б) окна +-- wave-1 179_ (avito {0,1,5,7,8}, cian {9,10,11,12,13}, yandex {14,15,17,18,19}); внутри +-- оставшихся свободных часов round-robin по городам в исходном 41-городском TSV-порядке +-- (novouralsk..bisert, bisert выброшен целиком), затем строка эмитится, только если +-- источник подтверждён для этого города. Итоговый максимум коллизий ОДНОГО источника в +-- одном часе: avito <= 2, cian <= 3, yandex <= 3 (ниже, чем было бы при полных 41 — +-- меньше строк на источник). Разные провайдеры МОГУТ делить час — не ограничивалось (см. +-- 179_/206_ — proxy-pool уже не единственный узел). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 179_ (wave 1, +-- CITY_ANCHORS-механизм и wildcard resolve_handler — не переопределяются здесь). +-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален +-- по построению (40 городов × подтверждённые источники, ни один не пересекается с +-- wave-1 5 городами). + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +-- ── avito_city_sweep_ — ТОЛЬКО 23 города с подтверждённым avito_slug +-- (radius_m 3000, detail_top_n 0 — issue #2827, enrich_houses true, +-- pages_per_anchor 3, request_delay_sec 7, interval_days 3) ────────────── +( + 'avito_city_sweep_novouralsk', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'avito_city_sweep_asbest', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'avito_city_sweep_bogdanovich', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'avito_city_sweep_irbit', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'avito_city_sweep_krasnoufimsk', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'avito_city_sweep_krasnoturinsk', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'avito_city_sweep_severouralsk', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'avito_city_sweep_ivdel', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'avito_city_sweep_tavda', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'avito_city_sweep_turinsk', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'avito_city_sweep_sysert', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_salda', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'avito_city_sweep_nizhnyaya_salda', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'avito_city_sweep_nevyansk', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'avito_city_sweep_alapaevsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'avito_city_sweep_krasnouralsk', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'avito_city_sweep_verkhniy_tagil', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'avito_city_sweep_rezh', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'avito_city_sweep_aramil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'avito_city_sweep_volchansk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_tura', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'avito_city_sweep_talitsa', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'avito_city_sweep_novaya_lyalya', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +), +-- ── cian_city_sweep_ — ВСЕ 40 городов (cian_id подтверждён у всех) +-- (radius_m 3000, detail_top_n 10, enrich_houses true, pages_per_anchor 3, +-- request_delay_sec 5, interval_days 3) ───────────────────────────────── +( + 'cian_city_sweep_novouralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'cian_city_sweep_revda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "revda"}'::jsonb +), +( + 'cian_city_sweep_polevskoy', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "polevskoy"}'::jsonb +), +( + 'cian_city_sweep_asbest', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'cian_city_sweep_bogdanovich', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'cian_city_sweep_irbit', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'cian_city_sweep_krasnoufimsk', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'cian_city_sweep_berezovskiy', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "berezovskiy"}'::jsonb +), +( + 'cian_city_sweep_zarechny', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "zarechny"}'::jsonb +), +( + 'cian_city_sweep_kachkanar', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kachkanar"}'::jsonb +), +( + 'cian_city_sweep_krasnoturinsk', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'cian_city_sweep_severouralsk', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'cian_city_sweep_ivdel', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'cian_city_sweep_tavda', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'cian_city_sweep_turinsk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'cian_city_sweep_sysert', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'cian_city_sweep_sredneuralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sredneuralsk"}'::jsonb +), +( + 'cian_city_sweep_degtyarsk', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "degtyarsk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_salda', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'cian_city_sweep_nizhnyaya_salda', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'cian_city_sweep_nevyansk', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'cian_city_sweep_artemovskiy', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "artemovskiy"}'::jsonb +), +( + 'cian_city_sweep_kamyshlov', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kamyshlov"}'::jsonb +), +( + 'cian_city_sweep_alapaevsk', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'cian_city_sweep_sukhoy_log', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sukhoy_log"}'::jsonb +), +( + 'cian_city_sweep_kushva', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kushva"}'::jsonb +), +( + 'cian_city_sweep_krasnouralsk', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'cian_city_sweep_karpinsk', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "karpinsk"}'::jsonb +), +( + 'cian_city_sweep_nizhnyaya_tura', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb +), +( + 'cian_city_sweep_verkhniy_tagil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'cian_city_sweep_nizhnie_sergi', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb +), +( + 'cian_city_sweep_lesnoy', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "lesnoy"}'::jsonb +), +( + 'cian_city_sweep_rezh', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'cian_city_sweep_aramil', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'cian_city_sweep_volchansk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_tura', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'cian_city_sweep_mikhaylovsk', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "mikhaylovsk"}'::jsonb +), +( + 'cian_city_sweep_verkhoturye', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhoturye"}'::jsonb +), +( + 'cian_city_sweep_talitsa', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'cian_city_sweep_novaya_lyalya', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +), +-- ── yandex_city_sweep_ — 39 городов (ВСЕ, КРОМЕ mikhaylovsk — города +-- нет в гео-базе Яндекса вообще) (radius_m 25000, pages_per_anchor 3, +-- request_delay_sec 9, interval_days 3) ─────────────────────────────── +( + 'yandex_city_sweep_novouralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'yandex_city_sweep_revda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "revda"}'::jsonb +), +( + 'yandex_city_sweep_polevskoy', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "polevskoy"}'::jsonb +), +( + 'yandex_city_sweep_asbest', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'yandex_city_sweep_bogdanovich', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'yandex_city_sweep_irbit', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'yandex_city_sweep_krasnoufimsk', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'yandex_city_sweep_berezovskiy', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "berezovskiy"}'::jsonb +), +( + 'yandex_city_sweep_zarechny', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "zarechny"}'::jsonb +), +( + 'yandex_city_sweep_kachkanar', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kachkanar"}'::jsonb +), +( + 'yandex_city_sweep_krasnoturinsk', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'yandex_city_sweep_severouralsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'yandex_city_sweep_ivdel', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'yandex_city_sweep_tavda', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'yandex_city_sweep_turinsk', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'yandex_city_sweep_sysert', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'yandex_city_sweep_sredneuralsk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sredneuralsk"}'::jsonb +), +( + 'yandex_city_sweep_degtyarsk', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "degtyarsk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_salda', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'yandex_city_sweep_nizhnyaya_salda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'yandex_city_sweep_nevyansk', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'yandex_city_sweep_artemovskiy', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "artemovskiy"}'::jsonb +), +( + 'yandex_city_sweep_kamyshlov', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kamyshlov"}'::jsonb +), +( + 'yandex_city_sweep_alapaevsk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'yandex_city_sweep_sukhoy_log', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sukhoy_log"}'::jsonb +), +( + 'yandex_city_sweep_kushva', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kushva"}'::jsonb +), +( + 'yandex_city_sweep_krasnouralsk', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'yandex_city_sweep_karpinsk', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "karpinsk"}'::jsonb +), +( + 'yandex_city_sweep_nizhnyaya_tura', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb +), +( + 'yandex_city_sweep_verkhniy_tagil', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'yandex_city_sweep_nizhnie_sergi', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb +), +( + 'yandex_city_sweep_lesnoy', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "lesnoy"}'::jsonb +), +( + 'yandex_city_sweep_rezh', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'yandex_city_sweep_aramil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'yandex_city_sweep_volchansk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_tura', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'yandex_city_sweep_verkhoturye', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhoturye"}'::jsonb +), +( + 'yandex_city_sweep_talitsa', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'yandex_city_sweep_novaya_lyalya', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). Источники перечислены в ' + 'tests/test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES и в сид-миграциях ' + 'data/sql/*scrape_schedules*seed*.sql. Последний добавленный: 102 wave-2 oblast ' + 'city-sweep source''ы (40 городов, только подтверждённые provider-id: ' + 'cian x40 / yandex x39 (без mikhaylovsk) / avito x23, #262 — все enabled=false, ' + 'defensive ValueError guard в pipeline.py против молчаливого ЕКБ-fallback).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 6b449178..4b98c273 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -250,3 +250,4 @@ 259_data_quality_drop_pct_cadastr.sql 260_houses_drop_has_panorama.sql 261_listings_search_mv_drop_placeholder_columns.sql +262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 3f132e1f..e138a150 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -31,20 +31,41 @@ def test_ekb_anchors_count() -> None: def test_resolve_city_name_known_oblast_slugs() -> None: - """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя.""" - from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name + """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя. - expected = { + #262 wave 2: CITY_LOCATIONS выросла с 5 (wave 1) до 45 (wave 1 + 40 wave-2 + городов) — вместо хардкода полного списка (дублировал бы CITY_DISPLAY_NAMES и + ломался при каждом новом городе) проверяем структурный инвариант: CITY_DISPLAY_ + NAMES обязан покрывать РОВНО те же slug'и, что CITY_LOCATIONS (иначе oblast-город + бы тихо получил ЕКБ-дефолт вместо своего имени) + spot-check wave-1 (не тронуты + этим PR) и по одному wave-2 city из каждой tier-группы (все три id / только + cian+yandex / только cian). + """ + from scraper_kit.orchestration.pipeline import ( + CITY_DISPLAY_NAMES, + CITY_LOCATIONS, + resolve_city_name, + ) + + wave1_expected = { "nizhniy_tagil": "Нижний Тагил", "kamensk_uralskiy": "Каменск-Уральский", "pervouralsk": "Первоуральск", "verkhnyaya_pyshma": "Верхняя Пышма", "serov": "Серов", } - # CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS - # (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени). - assert set(expected) == set(CITY_LOCATIONS) - for slug, name in expected.items(): + assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) + assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items() + for slug, name in wave1_expected.items(): + assert resolve_city_name(slug) == name + # wave-2 spot-check: novouralsk (avito+cian+yandex), revda (cian+yandex, avito + # НЕ подтверждён), mikhaylovsk (только cian, yandex отсутствует у источника). + for slug, name in { + "novouralsk": "Новоуральск", + "revda": "Ревда", + "mikhaylovsk": "Михайловск", + }.items(): + assert slug in CITY_LOCATIONS assert resolve_city_name(slug) == name @@ -62,6 +83,56 @@ def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None: assert resolve_city_name("nonexistent_city") == "Екатеринбург" +# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный +# ЕКБ-fallback) ─────────────────────────────────────────────────────────────────── + + +async def test_run_avito_city_sweep_raises_on_known_city_without_avito_slug() -> None: + """revda — известный CITY_LOCATIONS город, но avito_slug=None (не подтверждён). + + run_avito_city_sweep обязан упасть ДО любого сетевого/DB похода — иначе + `_city_seg()` молча взяла бы 'ekaterinburg' и sweep собрал бы ЕКБ под меткой + 'revda'. Raise происходит в самом начале функции (до await) — MagicMock() для + config/matcher/enrichment безопасен, до них не доходит.""" + from scraper_kit.orchestration.pipeline import run_avito_city_sweep + + with pytest.raises(ValueError, match="revda"): + await run_avito_city_sweep( + MagicMock(), + run_id=1, + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + city_slug="revda", + ) + + +async def test_run_yandex_city_sweep_raises_on_known_city_without_yandex_rgid() -> None: + """mikhaylovsk — известный CITY_LOCATIONS город, но yandex_rgid=None (город + отсутствует в гео-базе Яндекса вообще). run_yandex_city_sweep обязан упасть ДО + любого сетевого/DB похода — иначе YandexRealtyScraper(city_rgid=None) молча + взял бы ЕКБ rgid.""" + from scraper_kit.orchestration.pipeline import run_yandex_city_sweep + + with pytest.raises(ValueError, match="mikhaylovsk"): + await run_yandex_city_sweep( + MagicMock(), + run_id=1, + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + city_slug="mikhaylovsk", + ) + + +async def test_run_avito_city_sweep_does_not_raise_for_ekb_none_city_slug() -> None: + """city_slug=None (ЕКБ-путь, back-compat) НЕ должен затронуть новый guard — guard + условие `_loc is not None`, а get_city_location(None) возвращает None.""" + from scraper_kit.orchestration.pipeline import get_city_location + + assert get_city_location(None) is None + + # ── CitySweepCounters ─────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py new file mode 100644 index 00000000..75da91a6 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py @@ -0,0 +1,452 @@ +"""Static guards for migration 262 (wave 2 — оставшиеся 40 городов Свердловской обл.). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует живой +БД; здесь фиксируем структурные инварианты миграции (транзакционность, отсутствие DDL, +отсутствие psycopg CAST-ловушки, все enabled=false) и, ГЛАВНОЕ, parity-guard между +data/sql (что реально сеется в scrape_schedules) и `scraper_kit.orchestration.pipeline` +(`CITY_ANCHORS`/`CITY_LOCATIONS`, откуда sweep берёт координаты и provider-id по +`default_params->>'city'`): + + - каждый slug из миграций 179_/262_ ОБЯЗАН существовать в CITY_ANCHORS — иначе + `get_city_anchors(slug)` вернёт None и `_job_*_city_sweep` молча упадёт на + EKB_ANCHORS (город "включат", а сборка физически уйдёт в Екатеринбург); + - и наоборот: КАЖДЫЙ ключ CITY_ANCHORS (кроме None-пути ЕКБ) обязан иметь schedule- + строки где-то (179_ ИЛИ 262_); + - #262 ревью (после первой версии файла): строка для (provider, city) существует + ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий provider-идентификатор в CITY_LOCATIONS + подтверждён (не None) — НЕ "каждый город получает все 3 источника". Первая версия + заводила все 123 (41×3) строки, планируя добыть идентификаторы ПОСЛЕ — это был бы + ровно тот силентный ЕКБ-fallback баг, о котором она сама предупреждала. Этот файл + проверяет обратное соответствие в обе стороны для avito_slug/yandex_rgid/ + cian_region_id. + +Это ловит ИМЕННО тот класс регрессии, которого просил избежать заказчик: опечатка в +slug/рассинхрон идентификатора при заведении миграции, из-за которой sweep молча +резолвится на EKB_ANCHORS или ЕКБ region_id/rgid. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from scraper_kit.orchestration.pipeline import CITY_ANCHORS, CITY_LOCATIONS + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_179 = _SQL_DIR / "179_scrape_schedules_seed_oblast_city_sweeps.sql" +_MIGRATION_262 = _SQL_DIR / "262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql" + +_PROVIDERS = ("avito", "cian", "yandex") + +# source-строка внутри VALUES (), напр. " 'avito_city_sweep_nizhniy_tagil',". +# Ограничено 4-пробельным отступом + запятой в конце — не матчит примеры в header- +# комментариях (см. "UPDATE scrape_schedules SET enabled = true WHERE source = '...';"). +_ROW_SOURCE_RE = re.compile(r"^ '(avito|cian|yandex)_city_sweep_([a-z_]+)',$", re.MULTILINE) + +_WAVE1_SLUGS = { + "nizhniy_tagil", + "kamensk_uralskiy", + "pervouralsk", + "verkhnyaya_pyshma", + "serov", +} +_WAVE2_SLUGS = { + "novouralsk", + "revda", + "polevskoy", + "asbest", + "bogdanovich", + "irbit", + "krasnoufimsk", + "berezovskiy", + "zarechny", + "kachkanar", + "krasnoturinsk", + "severouralsk", + "ivdel", + "tavda", + "turinsk", + "sysert", + "sredneuralsk", + "degtyarsk", + "verkhnyaya_salda", + "nizhnyaya_salda", + "nevyansk", + "artemovskiy", + "kamyshlov", + "alapaevsk", + "sukhoy_log", + "kushva", + "krasnouralsk", + "karpinsk", + "nizhnyaya_tura", + "verkhniy_tagil", + "nizhnie_sergi", + "lesnoy", + "rezh", + "aramil", + "volchansk", + "verkhnyaya_tura", + "mikhaylovsk", + "verkhoturye", + "talitsa", + "novaya_lyalya", +} +assert len(_WAVE2_SLUGS) == 40 +assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана) + + +def _sql(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _executable_sql(path: Path) -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql(path).splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _row_sources(path: Path) -> list[tuple[str, str]]: + """(provider, slug) пар из исполняемых INSERT-строк VALUES (не из header-комментов).""" + return _ROW_SOURCE_RE.findall(_executable_sql(path)) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +# ── existence / basic shape ─────────────────────────────────────────────────── + + +def test_migration_262_exists() -> None: + assert _MIGRATION_262.exists(), f"missing migration: {_MIGRATION_262}" + + +def test_migration_262_is_transactional() -> None: + sql = _sql(_MIGRATION_262) + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_262_no_ddl() -> None: + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert "truncate" not in flat + + +def test_migration_262_no_psycopg_cast_trap() -> None: + assert not re.search(r":\w+::", _sql(_MIGRATION_262)) + + +def test_migration_262_idempotent_on_conflict_do_nothing() -> None: + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "on conflict (source) do nothing" in flat + # Одна INSERT-инструкция на весь файл (не 102 отдельных INSERT) — ON CONFLICT + # покрывает весь батч разом; повторный прогон всего файла — no-op. + assert flat.count("insert into scrape_schedules") == 1 + + +def test_migration_262_excludes_bisert_entirely() -> None: + """Бисерть — пгт, не город; у Циана её нет вообще (любой запрос отдаёт Сысерть). + + Regression-guard: ни одной строки source LIKE '%bisert%' в исполняемом SQL.""" + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "bisert" not in flat + + +# ── row counts (102 = 23 avito + 40 cian + 39 yandex) ─────────────────────── + + +def test_migration_262_has_exactly_102_rows() -> None: + rows = _row_sources(_MIGRATION_262) + assert len(rows) == 102, f"expected 23 avito + 40 cian + 39 yandex = 102 rows, got {len(rows)}" + + +def test_migration_262_row_counts_per_provider() -> None: + rows = _row_sources(_MIGRATION_262) + by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS} + for provider, slug in rows: + by_provider[provider].add(slug) + assert len(by_provider["avito"]) == 23 + assert len(by_provider["cian"]) == 40 + assert len(by_provider["yandex"]) == 39 + # cian — ровно все 40 wave-2 slug'ов (cian_region_id подтверждён у всех). + assert by_provider["cian"] == _WAVE2_SLUGS + # yandex — все, КРОМЕ mikhaylovsk. + assert by_provider["yandex"] == _WAVE2_SLUGS - {"mikhaylovsk"} + # avito — подмножество (только подтверждённые avito_slug), не пересекается с + # городами, у которых avito_slug=None в CITY_LOCATIONS. + assert by_provider["avito"] <= _WAVE2_SLUGS + + +def test_migration_262_no_duplicate_sources() -> None: + rows = _row_sources(_MIGRATION_262) + sources = [f"{p}_city_sweep_{s}" for p, s in rows] + assert len(sources) == len(set(sources)), "duplicate source в 262_" + + +def test_migration_262_does_not_touch_wave1_cities() -> None: + """Regression-guard: 262_ не должен переопределять/дублировать wave-1 5 городов — + они уже сидированы 179_ и живут (enabled управляется отдельно от них).""" + rows = _row_sources(_MIGRATION_262) + slugs = {slug for _provider, slug in rows} + overlap = slugs & _WAVE1_SLUGS + assert not overlap, f"262_ пересекается с wave-1 городами: {overlap}" + + +def test_migration_262_all_rows_enabled_false() -> None: + """Все 102 строки должны быть enabled=false (dormant by design — оператор включает + волнами вручную). Считаем по числу `false,` сразу после source-литерала.""" + executable = _executable_sql(_MIGRATION_262) + tuples = re.findall( + r"'(?:avito|cian|yandex)_city_sweep_[a-z_]+',\s*\n\s*(true|false),", + executable, + ) + assert len(tuples) == 102 + assert set(tuples) == {"false"}, "найдена строка с enabled=true — нарушение dormant-инварианта" + + +# ── CITY_LOCATIONS parity (ГЛАВНЫЙ regression-guard после ревью) ─────────── + + +def test_migration_262_row_exists_iff_identifier_confirmed() -> None: + """Строка (provider, city) есть в 262_ ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий + provider-идентификатор в CITY_LOCATIONS подтверждён (не None). + + Это единственный источник правды после ревью: НЕ "каждый wave-2 город получает 3 + строки" (так было в отклонённой первой версии — риск silent EKB-fallback), а + "строка существует ровно там, где есть подтверждённый id". + """ + rows = _row_sources(_MIGRATION_262) + by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS} + for provider, slug in rows: + by_provider[provider].add(slug) + + for slug in _WAVE2_SLUGS: + loc = CITY_LOCATIONS[slug] + + has_avito_row = slug in by_provider["avito"] + has_avito_id = loc.avito_slug is not None + assert has_avito_row == has_avito_id, ( + f"{slug}: avito_slug={loc.avito_slug!r} (confirmed={has_avito_id}) но " + f"avito-row-exists={has_avito_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + has_cian_row = slug in by_provider["cian"] + has_cian_id = loc.cian_region_id is not None + assert has_cian_row == has_cian_id, ( + f"{slug}: cian_region_id={loc.cian_region_id!r} (confirmed={has_cian_id}) но " + f"cian-row-exists={has_cian_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + has_yandex_row = slug in by_provider["yandex"] + has_yandex_id = loc.yandex_rgid is not None + assert has_yandex_row == has_yandex_id, ( + f"{slug}: yandex_rgid={loc.yandex_rgid!r} (confirmed={has_yandex_id}) но " + f"yandex-row-exists={has_yandex_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + +def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None: + """Каждый slug из CITY_LOCATIONS с непустым avito_slug имеет avito_city_sweep_ + строку в 262_ (wave-2) или 179_ (wave-1) — и наоборот, каждая avito-строка + принадлежит городу с непустым avito_slug.""" + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "avito"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "avito"} + scheduled_avito = rows_262 | rows_179 + + confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None} + assert scheduled_avito == confirmed_avito, ( + f"missing schedule rows: {confirmed_avito - scheduled_avito}; " + f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}" + ) + + +def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None: + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "yandex"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "yandex"} + scheduled_yandex = rows_262 | rows_179 + + confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None} + assert scheduled_yandex == confirmed_yandex, ( + f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; " + f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}" + ) + + +def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> None: + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "cian"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "cian"} + scheduled_cian = rows_262 | rows_179 + + confirmed_cian = { + slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None + } + assert scheduled_cian == confirmed_cian, ( + f"missing schedule rows: {confirmed_cian - scheduled_cian}; " + f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}" + ) + + +def test_mikhaylovsk_has_only_cian_row() -> None: + """Regression-guard для самого узкого случая: mikhaylovsk — единственный город без + yandex_rgid (город отсутствует в гео-базе Яндекса) и без avito_slug — должен иметь + РОВНО одну строку (cian), НЕ три.""" + rows = _row_sources(_MIGRATION_262) + mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"} + assert mikhaylovsk_providers == { + "cian" + }, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}" + + +# ── CITY_ANCHORS parity ───────────────────────────────────────────────────── + + +def test_migration_262_slugs_all_exist_in_city_anchors() -> None: + """Каждый slug из 262_ ОБЯЗАН быть в CITY_ANCHORS — иначе get_city_anchors(slug) + вернёт None и sweep молча резолвится на EKB_ANCHORS (собирает ЕКБ вместо города).""" + rows = _row_sources(_MIGRATION_262) + slugs = {slug for _provider, slug in rows} + missing = slugs - set(CITY_ANCHORS) + assert not missing, f"slugs из 262_ отсутствуют в CITY_ANCHORS: {sorted(missing)}" + + +def test_migration_179_slugs_all_exist_in_city_anchors() -> None: + """Тот же guard для wave-1 (179_) — sanity, что baseline не сломан этим PR.""" + rows = _row_sources(_MIGRATION_179) + slugs = {slug for _provider, slug in rows} + missing = slugs - set(CITY_ANCHORS) + assert not missing, f"slugs из 179_ отсутствуют в CITY_ANCHORS: {sorted(missing)}" + + +def test_city_anchors_has_no_slug_without_schedule_rows() -> None: + """Обратное направление: каждый ключ CITY_ANCHORS обязан иметь schedule-строки в + 179_ ИЛИ 262_ — мёртвая запись без сидов сигнализирует рассинхрон/опечатку.""" + seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | { + slug for _p, slug in _row_sources(_MIGRATION_262) + } + orphaned = set(CITY_ANCHORS) - seeded_slugs + assert ( + not orphaned + ), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}" + + +def test_city_anchors_wave2_count_and_content() -> None: + """CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 — + ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи).""" + assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS + assert len(CITY_ANCHORS) == 45 + + +def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None: + """Каждая wave-2 запись — ровно 1 anchor (город компактнее ЕКБ, см. коммент в + pipeline.py) с непустой человекочитаемой подписью, заканчивающейся на 'центр'.""" + for slug in _WAVE2_SLUGS: + anchors = CITY_ANCHORS[slug] + assert len(anchors) == 1, f"{slug}: ожидался 1 anchor, получено {len(anchors)}" + lat, lon, label = anchors[0] + assert 55.0 < lat < 62.0, f"{slug}: lat={lat} вне разумного диапазона Свердловской обл." + assert 56.0 < lon < 67.0, f"{slug}: lon={lon} вне разумного диапазона Свердловской обл." + assert label.strip().lower().endswith("центр"), f"{slug}: подпись '{label}' без 'центр'" + + +def test_city_locations_wave2_count_and_content() -> None: + """CITY_LOCATIONS покрывает ровно те же 45 slug'ов, что CITY_ANCHORS (иначе + get_city_location(slug) вернёт None для известного anchor'ами города и сборка + молча уйдёт на ЕКБ region_id/rgid).""" + assert set(CITY_LOCATIONS) == set(CITY_ANCHORS) + + +def test_city_locations_cian_region_id_never_none() -> None: + """cian_region_id подтверждён у ВСЕХ 45 городов (wave 1 + wave 2) — единственное + поле CityLocation, у которого нет Optional-пути.""" + for slug, loc in CITY_LOCATIONS.items(): + assert loc.cian_region_id is not None, f"{slug}: cian_region_id не должен быть None" + + +# ── window scheduling ─────────────────────────────────────────────────────── + +_EKB_OCCUPIED = {"avito": {6}, "cian": {2, 3, 4}, "yandex": {16}} +_WAVE1_OCCUPIED = { + "avito": {0, 1, 5, 7, 8}, + "cian": {9, 10, 11, 12, 13}, + "yandex": {14, 15, 17, 18, 19}, +} +# Математический минимум коллизий одного источника в одном часе (round-robin по +# 23/40/39 городам на 18/16/18 свободных часов соответственно). +_MAX_COLLISIONS = {"avito": 2, "cian": 3, "yandex": 3} + + +def _row_windows(path: Path) -> list[tuple[str, str, int, int]]: + """(provider, slug, window_start_hour, window_end_hour) для каждой VALUES-строки.""" + executable = _executable_sql(path) + pattern = re.compile( + r"'(avito|cian|yandex)_city_sweep_([a-z_]+)',\s*\n\s*(?:true|false),\s*\n\s*(\d+),\s*\n\s*(\d+),", + ) + return [(p, s, int(a), int(b)) for p, s, a, b in pattern.findall(executable)] + + +def test_migration_262_windows_are_one_hour() -> None: + """Окно ровно на час. Час 23 заворачивается в 0 (cross-midnight) — scheduler + это поддерживает явно (`window_end_hour <= window_start_hour` → окно через + полночь, см. scraper_kit.orchestration.scheduler).""" + for provider, slug, start, end in _row_windows(_MIGRATION_262): + expected = (start + 1) % 24 + assert ( + end == expected + ), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})" + + +def test_migration_262_window_hours_satisfy_db_check_constraint() -> None: + """Оба часа обязаны попадать в 0..23 — иначе миграция не применится вовсе. + + Регресс: первая версия давала start=23, end=24 у пяти строк. Проверка + «end == start + 1» это пропускала, а прод-констрейнт `window_end_range` + (052_scrape_schedules.sql: CHECK (window_end_hour BETWEEN 0 AND 23)) — + нет, и весь INSERT падал в CI на реальном postgres. + """ + for provider, slug, start, end in _row_windows(_MIGRATION_262): + assert 0 <= start <= 23, f"{provider}_{slug}: window_start_hour={start} вне 0..23" + assert 0 <= end <= 23, f"{provider}_{slug}: window_end_hour={end} вне 0..23" + + +def test_migration_262_windows_avoid_ekb_and_wave1_occupied_hours() -> None: + """Ни одна wave-2 строка не наезжает на занятые ЕКБ-окна (avito 6-7, cian 2-5, + yandex 16-17) или на окна wave-1 (179_) — тот же provider.""" + for provider, slug, start, _end in _row_windows(_MIGRATION_262): + occupied = _EKB_OCCUPIED[provider] | _WAVE1_OCCUPIED[provider] + assert start not in occupied, ( + f"{provider}_city_sweep_{slug}: окно start={start} пересекает занятый " + f"ЕКБ/wave-1 час ({sorted(occupied)})" + ) + + +def test_migration_262_same_provider_collisions_within_math_minimum() -> None: + """Коллизии ОДНОГО источника в одном окне не превышают математический минимум + (round-robin по подтверждённым городам на свободные часы), и распределены + равномерно (не сконцентрированы в 1-2 часах).""" + from collections import Counter + + rows = _row_windows(_MIGRATION_262) + for provider in _PROVIDERS: + hours = [start for p, _slug, start, _end in rows if p == provider] + counts = Counter(hours) + assert max(counts.values()) <= _MAX_COLLISIONS[provider], ( + f"{provider}: час {counts.most_common(1)} превышает математический минимум " + f"коллизий ({_MAX_COLLISIONS[provider]})" + ) + + +def test_migration_262_cross_provider_overlap_allowed() -> None: + """Разные провайдеры МОГУТ делить окно в рамках этой миграции (не ограничивается) — + документируем это явно, чтобы будущий рефактор не поломал предположение по ошибке.""" + rows = _row_windows(_MIGRATION_262) + avito_hours = {start for p, _s, start, _e in rows if p == "avito"} + cian_hours = {start for p, _s, start, _e in rows if p == "cian"} + assert avito_hours and cian_hours diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index b3d462d5..81631271 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -272,12 +272,65 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [ # default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек # на большой город), эти города компактнее — одного anchor'а достаточно. # Domclick (BFF, city_id-based, НЕ anchors-based) сюда не входит — отдельный B2 rollout. +# +# Wave 1 (5 городов, ниже) — enabled на проде. Wave 2 (40 городов, оставшаяся часть +# Свердловской обл. КРОМЕ Бисерти — пгт, не город, Циан её не резолвит вообще, см. +# CITY_LOCATIONS-коммент ниже; data/sql/262_scrape_schedules_seed_oblast_city_sweeps_ +# wave2.sql) — координаты проверены оператором (прод-геокодер + независимая сверка +# медианой координат сделок Росреестра по городу), все schedule-строки enabled=false +# (оператор включает волнами вручную). CITY_LOCATIONS заведён для всех 40 (см. ниже) — +# но НЕ у каждого города подтверждены все 3 provider-идентификатора: run_avito_city_ +# sweep/run_yandex_city_sweep явно падают (ValueError), если для известного city_slug +# соответствующий идентификатор не подтверждён (None), вместо молчаливого ЕКБ-дефолта. +# 262_ заводит schedule-строку ТОЛЬКО там, где идентификатор источника подтверждён — +# поэтому этот ValueError в проде не должен срабатывать при штатной эксплуатации. CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = { "nizhniy_tagil": [(57.910, 59.980, "Н.Тагил центр")], "kamensk_uralskiy": [(56.414, 61.918, "Каменск центр")], "pervouralsk": [(56.908, 59.943, "Первоуральск центр")], "verkhnyaya_pyshma": [(56.976, 60.578, "В.Пышма центр")], "serov": [(59.604, 60.578, "Серов центр")], + # ── wave 2 (40 городов, dormant — см. коммент выше; Бисерть исключена целиком) ── + "novouralsk": [(57.2470, 60.0958, "Новоуральск центр")], + "revda": [(56.8001, 59.9087, "Ревда центр")], + "polevskoy": [(56.4958, 60.2370, "Полевской центр")], + "asbest": [(57.0141, 61.4659, "Асбест центр")], + "bogdanovich": [(56.7683, 62.0521, "Богданович центр")], + "irbit": [(57.6810, 63.0585, "Ирбит центр")], + "krasnoufimsk": [(56.6181, 57.7713, "Красноуфимск центр")], + "berezovskiy": [(56.9098, 60.8120, "Берёзовский центр")], + "zarechny": [(56.8023, 61.3202, "Заречный центр")], + "kachkanar": [(58.6995, 59.4811, "Качканар центр")], + "krasnoturinsk": [(59.7646, 60.1936, "Краснотурьинск центр")], + "severouralsk": [(60.1502, 59.9605, "Североуральск центр")], + "ivdel": [(60.6973, 60.4173, "Ивдель центр")], + "tavda": [(58.0414, 65.2720, "Тавда центр")], + "turinsk": [(58.0525, 63.6909, "Туринск центр")], + "sysert": [(56.5054, 60.8097, "Сысерть центр")], + "sredneuralsk": [(56.9916, 60.4689, "Среднеуральск центр")], + "degtyarsk": [(56.6983, 60.0868, "Дегтярск центр")], + "verkhnyaya_salda": [(58.0494, 60.5473, "В.Салда центр")], + "nizhnyaya_salda": [(58.0714, 60.7147, "Н.Салда центр")], + "nevyansk": [(57.4924, 60.2105, "Невьянск центр")], + "artemovskiy": [(57.3386, 61.8941, "Артёмовский центр")], + "kamyshlov": [(56.8462, 62.7122, "Камышлов центр")], + "alapaevsk": [(57.8508, 61.6950, "Алапаевск центр")], + "sukhoy_log": [(56.9047, 62.0350, "Сухой Лог центр")], + "kushva": [(58.2817, 59.7625, "Кушва центр")], + "krasnouralsk": [(58.3571, 60.0374, "Красноуральск центр")], + "karpinsk": [(59.7668, 60.0009, "Карпинск центр")], + "nizhnyaya_tura": [(58.6208, 59.8473, "Н.Тура центр")], + "verkhniy_tagil": [(57.3745, 59.9321, "В.Тагил центр")], + "nizhnie_sergi": [(56.6619, 59.3000, "Н.Серги центр")], + "lesnoy": [(58.6364, 59.7897, "Лесной центр")], + "rezh": [(57.3706, 61.4043, "Реж центр")], + "aramil": [(56.6963, 60.8331, "Арамиль центр")], + "volchansk": [(59.9347, 60.0780, "Волчанск центр")], + "verkhnyaya_tura": [(58.3605, 59.8182, "В.Тура центр")], + "mikhaylovsk": [(56.4445, 59.1242, "Михайловск центр")], + "verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")], + "talitsa": [(57.0148, 63.7302, "Талица центр")], + "novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")], } @@ -325,23 +378,86 @@ class CityLocation: avito_slug — city-slug в пути avito URL (ОТЛИЧАЕТСЯ от нашего slug у некоторых городов: kamensk-uralskiy через дефис, verhnyaya_pyshma без kh). + Optional (#262 wave 2): у части городов НЕ подтверждён (403/429 — + пул исчерпан на проверке, либо omonym-коллизия). None здесь — + ЖЁСТКИЙ инвариант "нет достоверного значения", НЕ "бери ЕКБ" — + run_avito_city_sweep явно падает (ValueError), если city_slug + известен, а avito_slug для него None (см. её тело). cian_region_id — числовой region= в ekb.cian.ru/cat.php (ГОРОД, не область). - yandex_rgid — rgid в gate-API Яндекс.Недвижимости (город). + Подтверждён у ВСЕХ городов wave 1+2 — required, не Optional. + yandex_rgid — rgid в gate-API Яндекс.Недвижимости (город). Optional (#262 wave + 2): mikhaylovsk отсутствует в гео-базе Яндекса вообще (единственный + "Михайловск" там — ставропольский, чужой rgid подставлять нельзя). + Тот же жёсткий инвариант, что у avito_slug — run_yandex_city_sweep + явно падает (ValueError), если city_slug известен, а yandex_rgid + для него None. """ - avito_slug: str + avito_slug: str | None cian_region_id: int - yandex_rgid: int + yandex_rgid: int | None # Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах, # в карте держать НЕ обязательно (None-путь = ЕКБ). Города Свердловской обл. (region 66): +# +# Wave 2 (#262, 40 городов ниже wave-1 пятёрки) — идентификаторы добыты и валидированы +# отдельно по каждому provider (метод + валидация 5/5 на wave-1 городах — см. шапку +# data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql). Бисерть ИСКЛЮЧЕНА +# целиком (не город, пгт — у Циана её нет, любой запрос отдаёт Сысерть id=176028). +# avito_slug=None у 17 городов (не подтверждён — пул исчерпан на 429, ПЕРЕПРОВЕРИТЬ +# позже, не опровергнут); yandex_rgid=None только у mikhaylovsk (город отсутствует в +# гео-базе Яндекса — это НЕ "не проверили", это подтверждённое отсутствие данных). CITY_LOCATIONS: dict[str, CityLocation] = { "nizhniy_tagil": CityLocation("nizhniy_tagil", 4886, 328286), "kamensk_uralskiy": CityLocation("kamensk-uralskiy", 4781, 328468), "pervouralsk": CityLocation("pervouralsk", 4925, 328379), "verkhnyaya_pyshma": CityLocation("verhnyaya_pyshma", 4697, 166366), "serov": CityLocation("serov", 4982, 328238), + # ── wave 2: avito+cian+yandex все подтверждены (23 города) ────────────────── + "novouralsk": CityLocation("novouralsk", 4900, 327473), + "asbest": CityLocation("asbest", 4659, 328344), + "bogdanovich": CityLocation("bogdanovich", 4684, 327697), + "irbit": CityLocation("irbit", 4773, 327182), + "krasnoufimsk": CityLocation("krasnoufimsk", 4826, 326938), + "krasnoturinsk": CityLocation("krasnoturinsk", 4824, 328232), + "severouralsk": CityLocation("severouralsk", 4979, 328576), + "ivdel": CityLocation("ivdel", 4769, 328471), + "tavda": CityLocation("tavda", 5007, 328612), + "turinsk": CityLocation("turinsk", 176151, 327028), + "sysert": CityLocation("sysert", 176028, 327787), + "verkhnyaya_salda": CityLocation("verhnyaya_salda", 4698, 328268), + "nizhnyaya_salda": CityLocation("nizhnyaya_salda", 4887, 328267), + "nevyansk": CityLocation("nevyansk", 4879, 327237), + "alapaevsk": CityLocation("alapaevsk", 4642, 328292), + "krasnouralsk": CityLocation("krasnouralsk", 4825, 328611), + "verkhniy_tagil": CityLocation("verhniy_tagil", 4695, 328510), + "rezh": CityLocation("rezh", 4957, 328306), + "aramil": CityLocation("aramil", 174206, 327631), + "volchansk": CityLocation("volchansk", 4710, 328231), + "verkhnyaya_tura": CityLocation("verhnyaya_tura", 4699, 328489), + "talitsa": CityLocation("talitsa", 176053, 328369), + "novaya_lyalya": CityLocation("novaya_lyalya", 175466, 328244), + # ── wave 2: avito НЕ подтверждён (429/403 — пул исчерпан, не опровергнут; либо + # omonym без avito-специфичной дизамбигуации) — только cian+yandex (16 городов) ── + "revda": CityLocation(None, 4956, 328540), + "polevskoy": CityLocation(None, 4937, 328436), + "berezovskiy": CityLocation(None, 4679, 559131), + "zarechny": CityLocation(None, 4754, 328386), + "kachkanar": CityLocation(None, 4792, 327958), + "sredneuralsk": CityLocation(None, 5000, 327474), + "degtyarsk": CityLocation(None, 4730, 328381), + "artemovskiy": CityLocation(None, 4657, 156418), + "kamyshlov": CityLocation(None, 4784, 328411), + "sukhoy_log": CityLocation(None, 5004, 328395), + "kushva": CityLocation(None, 4836, 328492), + "karpinsk": CityLocation(None, 4790, 328477), + "nizhnyaya_tura": CityLocation(None, 4888, 328487), + "nizhnie_sergi": CityLocation(None, 175439, 328114), + "lesnoy": CityLocation(None, 4844, 327968), + "verkhoturye": CityLocation(None, 174499, 328251), + # ── wave 2: ни avito, ни yandex — только cian (1 город) ───────────────────── + "mikhaylovsk": CityLocation(None, 175332, None), } @@ -367,6 +483,50 @@ CITY_DISPLAY_NAMES: dict[str, str] = { "pervouralsk": "Первоуральск", "verkhnyaya_pyshma": "Верхняя Пышма", "serov": "Серов", + # ── wave 2 (40 городов, #262) — заведены для ВСЕХ, независимо от того, какие + # provider-идентификаторы подтверждены в CITY_LOCATIONS: без записи здесь + # resolve_city_name() тихо вернула бы "Екатеринбург" для листингов города, + # у которого хотя бы один sweep-источник реально enabled. + "novouralsk": "Новоуральск", + "revda": "Ревда", + "polevskoy": "Полевской", + "asbest": "Асбест", + "bogdanovich": "Богданович", + "irbit": "Ирбит", + "krasnoufimsk": "Красноуфимск", + "berezovskiy": "Берёзовский", + "zarechny": "Заречный", + "kachkanar": "Качканар", + "krasnoturinsk": "Краснотурьинск", + "severouralsk": "Североуральск", + "ivdel": "Ивдель", + "tavda": "Тавда", + "turinsk": "Туринск", + "sysert": "Сысерть", + "sredneuralsk": "Среднеуральск", + "degtyarsk": "Дегтярск", + "verkhnyaya_salda": "Верхняя Салда", + "nizhnyaya_salda": "Нижняя Салда", + "nevyansk": "Невьянск", + "artemovskiy": "Артёмовский", + "kamyshlov": "Камышлов", + "alapaevsk": "Алапаевск", + "sukhoy_log": "Сухой Лог", + "kushva": "Кушва", + "krasnouralsk": "Красноуральск", + "karpinsk": "Карпинск", + "nizhnyaya_tura": "Нижняя Тура", + "verkhniy_tagil": "Верхний Тагил", + "nizhnie_sergi": "Нижние Серги", + "lesnoy": "Лесной", + "rezh": "Реж", + "aramil": "Арамиль", + "volchansk": "Волчанск", + "verkhnyaya_tura": "Верхняя Тура", + "mikhaylovsk": "Михайловск", + "verkhoturye": "Верхотурье", + "talitsa": "Талица", + "novaya_lyalya": "Новая Ляля", } EKATERINBURG_CITY_NAME = "Екатеринбург" @@ -994,6 +1154,21 @@ async def run_avito_city_sweep( # None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже). _anchors = anchors if anchors is not None else EKB_ANCHORS _loc = get_city_location(city_slug) + # #262 wave 2: avito_slug у CityLocation Optional — не у каждого известного города + # он подтверждён (403/429 на исчерпанном пуле при проверке, либо omonym-коллизия). + # Известный city_slug БЕЗ подтверждённого avito_slug — explicit fail, а НЕ молчаливый + # fallback на ЕКБ: `_city_seg()` в AvitoScraper делает `target_city_slug or + # "ekaterinburg"`, поэтому city_slug=None ниже незаметно собрал бы Екатеринбург под + # меткой города-цели. scrape_schedules-сиды (262_) заводят avito_city_sweep_ + # ТОЛЬКО там, где avito_slug подтверждён — этот раздел кода не должен исполняться в + # штатной эксплуатации; сработавший ValueError сигнализирует рассинхрон данных. + if _loc is not None and _loc.avito_slug is None: + raise ValueError( + f"run_avito_city_sweep: city_slug={city_slug!r} есть в CITY_LOCATIONS, но " + "avito_slug не подтверждён (None) — отказ от молчаливого fallback на " + "ekaterinburg. Подтверди avito_slug для этого города, прежде чем заводить " + "avito_city_sweep_ schedule." + ) # kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug — # вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor. _avito_slug = _loc.avito_slug if _loc else city_slug @@ -1932,6 +2107,20 @@ async def run_yandex_city_sweep( # city_slug (#12): rgid города-цели → YandexRealtyScraper.city_rgid скоупит SERP # на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе. _loc = get_city_location(city_slug) + # #262 wave 2: yandex_rgid у CityLocation Optional — mikhaylovsk (Нижнесергинский + # р-н) отсутствует в гео-базе Яндекс.Недвижимости вообще (единственный там — + # ставропольский Михайловск, rgid 586221, подставлять чужой rgid нельзя). Известный + # city_slug БЕЗ подтверждённого yandex_rgid — explicit fail, а НЕ молчаливый fallback + # на ЕКБ rgid (YandexRealtyScraper(city_rgid=None) конструктор молча берёт ЕКБ). + # scrape_schedules-сиды (262_) НЕ заводят yandex_city_sweep_ для городов без + # rgid — этот раздел не должен исполняться в штатной эксплуатации. + if _loc is not None and _loc.yandex_rgid is None: + raise ValueError( + f"run_yandex_city_sweep: city_slug={city_slug!r} есть в CITY_LOCATIONS, но " + "yandex_rgid не подтверждён (None) — отказ от молчаливого fallback на ЕКБ " + "rgid. Город отсутствует в гео-базе Яндекса — не заводи yandex_city_sweep_" + " schedule для него вовсе." + ) # #2594: город для save_listings(..., city=...) — один на весь sweep. _city_name = resolve_city_name(city_slug) # Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard From a068e4b612db47f757235cf827140f17eebe6b40 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 21:05:54 +0000 Subject: [PATCH 26/60] =?UTF-8?q?test(ci):=20=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BA=D1=80=D0=B8=D1=87=D0=B8=D1=82=20::error::=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20Actions=20(#2871)=20(#2876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/conftest.py | 13 ++++++-- backend/tests/test_skip_guard_loud.py | 44 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_skip_guard_loud.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a3266cb6..5276e7b2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -96,15 +96,24 @@ def pytest_sessionfinish(session, exitstatus) -> None: unlisted = sorted(_observed_skips - _allowed_skips()) if not unlisted: return - print( - f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + head = ( + f"НЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" ) + print(f"\n{head}") for nodeid in unlisted: print(f" - {nodeid}") print( "Почини тест либо внеси его в skip_allowlist.txt с причиной — " "пропуск без записи неотличим от пройденной проверки." ) + # #2871: под Actions дублируем в ::error:: — иначе сообщение тонет. + # 13.08 этот сторож четыре прогона подряд ронял job'у совершенно правильно, + # а его строка лежала посреди тысячи других (обычный print, по-русски) — + # и поиск по «FAILED / ERROR» её не находил. Причину искали три часа + # в диске, раннере, покрытии и кэше. Сторож, который роняет прогон, + # обязан кричать так, чтобы его нашли. + if os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"): + print(f"::error::{head} " + "; ".join(unlisted)) if exitstatus == 0: session.exitstatus = 1 diff --git a/backend/tests/test_skip_guard_loud.py b/backend/tests/test_skip_guard_loud.py new file mode 100644 index 00000000..6f83d126 --- /dev/null +++ b/backend/tests/test_skip_guard_loud.py @@ -0,0 +1,44 @@ +"""Проверка, что сторож пропусков кричит под Actions (#2871).""" + +from __future__ import annotations + +import types + +import tests.conftest as ct + + +def _run_guard(monkeypatch, capsys, *, ci: bool, observed: set[str]) -> str: + monkeypatch.setattr(ct, "_observed_skips", observed) + monkeypatch.setattr(ct, "_allowed_skips", lambda: set()) + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.delenv("CI", raising=False) + if ci: + monkeypatch.setenv("GITHUB_ACTIONS", "true") + session = types.SimpleNamespace(exitstatus=0) + ct.pytest_sessionfinish(session, 0) + return capsys.readouterr().out, session.exitstatus + + +def test_guard_emits_error_annotation_under_actions(monkeypatch, capsys) -> None: + out, rc = _run_guard(monkeypatch, capsys, ci=True, observed={"tests/x.py::test_y"}) + assert "::error::" in out, "под Actions сторож обязан подниматься в аннотации" + assert "tests/x.py::test_y" in out + assert rc == 1 + + +def test_guard_stays_quiet_locally(monkeypatch, capsys) -> None: + """Контроль: локально ::error:: не нужен, человеческое сообщение остаётся.""" + out, rc = _run_guard(monkeypatch, capsys, ci=False, observed={"tests/x.py::test_y"}) + assert "::error::" not in out + assert "НЕУЧТЁННЫЙ ПРОПУСК" in out + assert rc == 1 + + +def test_guard_silent_when_all_skips_declared(monkeypatch, capsys) -> None: + """Контроль: без незадекларированных пропусков сторож молчит и не роняет.""" + monkeypatch.setattr(ct, "_observed_skips", set()) + monkeypatch.setattr(ct, "_allowed_skips", lambda: set()) + session = types.SimpleNamespace(exitstatus=0) + ct.pytest_sessionfinish(session, 0) + assert capsys.readouterr().out == "" + assert session.exitstatus == 0 From f600663aa00a32e298f6f7e123b2e0dba49bdd7f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 21:21:38 +0000 Subject: [PATCH 27/60] =?UTF-8?q?docs(ptica):=20=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D1=83=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=82=D1=83=20=D0=B2=D0=B5=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B8=D0=BD=D1=83=20(#2464-H)=20(#2878)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/forecasting/confidence_engine.py | 55 ++++++++++++------- .../services/forecasting/report_assembler.py | 24 +++++--- .../forecasting/test_confidence_engine.py | 28 +++++++++- 3 files changed, 77 insertions(+), 30 deletions(-) diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 4ef5b203..3e4aaec3 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -3,10 +3,10 @@ #990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/ #985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов, -покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень +покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог / -покрытие domrf↔objective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987. +цена известна у 12% ближних ЖК»). Наполняет слот `ReportConfidence` отчёта #987. ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x — движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только @@ -28,8 +28,10 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, мало сделок → скоростные метрики статистически ненадёжны. • analog_count (ЖК-аналоги, = market_metrics.obj_count) — high≥3 / medium≥2 / 1 → low (точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример). - • domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics - docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён. + • domrf_coverage — имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой + из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга + domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08: + медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже. • history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий ряд → связь rate↔sales / тренды не установлены. • confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный @@ -91,9 +93,11 @@ _DEAL_COUNT_LOW: int = 15 _ANALOG_COUNT_HIGH: int = 3 _ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low -# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%). -# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог -# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства). +# domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1] (имя ключа историческое, +# см. _coverage_factor). high — покрытие плотное; low — рынок оценён по меньшинству ЖК. +# medium-порог созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6. +# NB: пороги подбирались под ожидавшиеся ~2.5% покрытия маппинга, а реальная величина +# другого порядка (медиана 40%) — их стоит пересмотреть отдельно, замером, а не на глаз. _DOMRF_COVERAGE_HIGH: float = 0.6 _DOMRF_COVERAGE_LOW: float = 0.2 @@ -252,23 +256,36 @@ _QUALITY_WORD: dict[Confidence, str] = { def _coverage_factor(coverage: float | None) -> ConfidenceFactor: - """domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE. + """Покрытие рынка ценами Objective ∈ [0,1] → ConfidenceFactor с % в ноте. PURE. - Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ - (структурный §15-пример «покрытие domrf↔objective 2.5%»). None → low. + #2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие + маппинга domrf↔objective, но такого продьюсера НЕТ и не было: слот + `supply_layers.domrf_coverage` никто не заполняет (см. явную оговорку в + `orchestrator._summarize_supply_layers`), и значение ВСЕГДА приходит из + `analyze.market_data_coverage_pct` = `competitors_priced / competitors_total`, + то есть доля БЛИЖНИХ ЖК (3 км), у которых есть цена из Objective. + + Замер на проде 13.08: 2074 анализа, min 0% · медиана 40% · среднее 31.7% · + max 70%. Это не «~2.5% покрытия domrf↔objective», как было написано здесь + раньше, — другая величина другого порядка. + + Ключ фактора НЕ переименован намеренно: его читает фронт + (`ForecastConfidenceBlock`, `ConfidencePanel`) как стабильный контракт. + Порог и значение не меняются — правится только то, что читает человек. + None → low. """ level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW) if coverage is None: note = ( - "Доля будущих проектов с известными планировками и площадями неизвестна — " - "оценка будущего предложения и конкуренции менее надёжна" + "Доля ближних ЖК с известной ценой из Objective неизвестна — " + "оценка рынка и конкуренции менее надёжна" ) else: pct = round(float(coverage) * 100.0, 1) note = ( - f"Известные планировки и площади есть у {pct}% будущих проектов " - f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза " - "будущего предложения и конкуренции" + f"Цена из Objective известна у {pct}% ближних ЖК " + f"({_QUALITY_WORD[level]}) — от этого зависит точность оценки " + "рынка и конкуренции" ) return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note) @@ -294,9 +311,7 @@ def _history_factor(history_months: int | None) -> ConfidenceFactor: "ряде тренды и чувствительность спроса к ставке оцениваются хуже " "(поэтому в 6.2 может остаться один сценарий вместо трёх)" ) - return ConfidenceFactor( - name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note - ) + return ConfidenceFactor(name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note) def _confounded_factor(confounded: bool) -> ConfidenceFactor: @@ -479,7 +494,9 @@ def compute_report_confidence( deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес» в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода. analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count). - domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта). + domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1]. Имя ключа + историческое — про маппинг domrf↔objective, продьюсера для которого + нет и не было (#2464-H, см. _coverage_factor). history_months: глубина ряда (мес). confounded: True, если окно ряда пересекает шок-период (PR2). advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда). diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index 33c85dd5..9a5d6817 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -203,15 +203,23 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None: - """Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE. + """Покрытие рынка ценами Objective ∈ [0,1] — для фактора domrf_coverage. PURE. - Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ - per-branch — НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8% - спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе, - который §15 призван флагать): - • `supply_layers.domrf_coverage` — уже ДОЛЯ ∈ [0,1] (0.025) → берём как есть. - • `analyze.market_data_coverage_pct` — всегда ПРОЦЕНТ (2.5 == 2.5%) → /100 → доля. - Нет сигнала → None (#990 → тянет в low: слой §9.3 недооценён). + Источники по приоритету (единица ЯВНАЯ per-branch — НЕ угадываем по величине, + иначе настоящий sub-1% процент типа 0.8% спутался бы с долей 0.8 = 80%): + • `supply_layers.domrf_coverage` — ДОЛЯ ∈ [0,1] → берём как есть. + • `analyze.market_data_coverage_pct` — ПРОЦЕНТ (40 == 40%) → /100 → доля. + Нет сигнала → None. + + #2464-H, важно для читающего: **первая ветка не исполнялась ни разу**. Слот + `supply_layers.domrf_coverage` никто не заполняет — `_summarize_supply_layers` + в orchestrator это прямо оговаривает («domrf_coverage здесь НЕ выводим — нет + дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и + величина у неё другая: не «покрытие маппинга domrf↔objective ~2.5%», как + было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective — + замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%. + + Порядок веток оставлен: если продьюсер появится, приоритет у него. """ if supply_layers is not None: coverage = supply_layers.get("domrf_coverage") diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py index 49c48a8d..b946e28f 100644 --- a/backend/tests/services/forecasting/test_confidence_engine.py +++ b/backend/tests/services/forecasting/test_confidence_engine.py @@ -133,19 +133,33 @@ class TestFactorFromCount: assert "12.5 мес истории" in f_frac.note -# ── _coverage_factor — покрытие domrf↔objective в % ──────────────────────────── +# ── _coverage_factor — покрытие рынка ценами Objective в % ───────────────────── class TestCoverageFactor: def test_low_coverage_percent_in_note(self) -> None: - # Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15). + # 2.5% покрытия → low, % в ноте (структурный §15). f = _coverage_factor(0.025) assert f.level == "low" assert f.value == 0.025 assert "2.5%" in f.note # #1963: нота человеческая, без внутр.жаргона «domrf↔objective». assert "domrf" not in f.note - assert "будущ" in f.note # говорит про будущее предложение/проекты + + def test_note_names_what_is_actually_measured(self) -> None: + """#2464-H: нота называет ближние ЖК и цену, а не «будущие проекты». + + Значение фактора ВСЕГДА приходит из `analyze.market_data_coverage_pct` + = competitors_priced / competitors_total, то есть доля ближних ЖК (3 км) + с ценой из Objective. Слот `supply_layers.domrf_coverage`, под который + писалась старая формулировка, никто не заполняет. + """ + f = _coverage_factor(0.4) + assert "ближних ЖК" in f.note, f.note + assert "Objective" in f.note, f.note + assert ( + "будущ" not in f.note + ), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами" def test_high_coverage(self) -> None: f = _coverage_factor(0.75) @@ -158,6 +172,14 @@ class TestCoverageFactor: assert "неизвестн" in f.note assert "domrf" not in f.note + def test_factor_key_unchanged(self) -> None: + """Ключ фактора остаётся `domrf_coverage` — его читает фронт. + + Контроль к правке #2464-H: меняем только человеческий текст, не контракт + (ForecastConfidenceBlock / ConfidencePanel маппят имя в RU-подпись). + """ + assert _coverage_factor(0.4).name == "domrf_coverage" + def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None: # BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден). # До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) — From fe019f26ee5a7f5eddb852fa8184d8c1478b58d6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 14 Aug 2026 10:58:35 +0500 Subject: [PATCH 28/60] =?UTF-8?q?fix(ptica):=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B0=20=D0=A6=D0=9F=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=BF=D0=B8=D1=88=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лоадер ЕЭСК писал степень загрузки из колонки E как `load_index = COALESCE(load_index, CAST(:load_pct AS text))`. load_index — категориальная: 'open'|'limited'|'closed'|NULL (data/sql/180_connection_capacity.sql:35), её заполняет rosseti_wfs_loader._map_load_index. Число строкой в этой колонке ломает обе стороны: фронтовый classifyLoadIndex отбрасывает всё вне перечисления в null («неизвестно»), а power_summary.by_load_index — словарь по значению, то есть получил бы бакет с именем вида "41.0" рядом с open/limited/closed. Сегодня не стреляло только потому, что load_index заполнен у всех строк (open 2741 / limited 346 / closed 329, NULL 0 — замер верификации 13.08, подтверждён вторым прогоном скептика), и COALESCE не проваливался. Первая же строка с пустым индексом положила бы туда число. Колонку E больше не читаем: места под процент в power_supply_centers нет — load_index категориальный, current_load_mva в мегавольт-амперах. _pct_share_to_percent оставлен с тестами, но в докстроке теперь прямо написано, что продакшен-вызывающих у него НЕТ и при каких условиях он снова понадобится — чтобы «код есть, эффекта нет» не выглядел работающим. Старый тест фиксировал ровно отменяемое поведение (`first["load_pct"] == 41.0`) — заменён на проверку, что ни SQL, ни параметры загрузку не несут. Проверять пришлось исполняемый текст, а не прозу: слово load_index осталось в поясняющем комментарии, и наивная проверка на подстроку падала на своём же объяснении. Тесты двусторонние: против лоадера из main падает ровно новый. Хунк форматирования — не мой: pre-commit ruff v0.7.4 против 0.15.12 (#2864). Refs #2464 --- .../site_finder/eesk_reserve_loader.py | 36 +++++++++++++++---- backend/tests/test_eesk_reserve_loader.py | 28 ++++++++++++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/backend/app/services/site_finder/eesk_reserve_loader.py b/backend/app/services/site_finder/eesk_reserve_loader.py index c396321b..937e3b29 100644 --- a/backend/app/services/site_finder/eesk_reserve_loader.py +++ b/backend/app/services/site_finder/eesk_reserve_loader.py @@ -169,7 +169,16 @@ def _cell(row: tuple, idx: int) -> object: def _pct_share_to_percent(value: object) -> float | None: """Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем. - В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах. + В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). + + #2464-B: продакшен-вызывающих у функции СЕЙЧАС НЕТ. Значение колонки E + раньше писалось в `load_index`, но это категориальная колонка + ('open'|'limited'|'closed'|NULL) — число в ней фронт отбрасывает в + «неизвестно» и плодит мусорный бакет в `power_summary.by_load_index`. + Функцию оставляю с тестами: она описывает формат листа, и она понадобится + в тот момент, когда под процент загрузки заведут числовую колонку. + Если такого решения не будет — удалить вместе с тестом, а не держать молча. + None/мусор → None. """ num = parse_reserve_number(value) @@ -214,7 +223,9 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> rows_seen += 1 district = _cell(row, 1) # B - load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %) + # Колонку E (степень загрузки ЦП долей) НЕ читаем и не храним: места + # под неё в power_supply_centers нет — load_index категориальный, + # current_load_mva в мегавольт-амперах (#2464-B, см. UPDATE ниже). reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт) name_norm = normalize_sc_name(str(sc_name)) @@ -223,7 +234,6 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> "reserve": reserve, "asof": reserve_asof, "district": str(district).strip() if district else None, - "load_pct": load_pct, "name_norm": name_norm, } @@ -236,10 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> reserve_unit = 'МВт', installed_capacity_mva = :installed, district = :district, - load_index = COALESCE( - load_index, - CAST(:load_pct AS text) - ), + -- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки. + -- load_index — категориальная колонка + -- ('open'|'limited'|'closed'|NULL, см. + -- data/sql/180_connection_capacity.sql:35), её + -- заполняет rosseti_wfs_loader._map_load_index. + -- Раньше тут стоял COALESCE(load_index, + -- CAST(:load_pct AS text)) — при пустой ячейке + -- в колонку легло бы число строкой ("72.5"), + -- а фронтовый classifyLoadIndex такое значение + -- отбрасывает в null («неизвестно»), и в + -- power_summary.by_load_index появился бы + -- бакет с именем "72.5". + -- Сегодня не стреляло только потому, что у всех + -- 3416 строк load_index уже заполнен + -- (open 2741 / limited 346 / closed 329, NULL 0) + -- и COALESCE не проваливался. capacity_source = 'eesk_35_220', reserve_asof = :asof WHERE sc_name_norm = :name_norm diff --git a/backend/tests/test_eesk_reserve_loader.py b/backend/tests/test_eesk_reserve_loader.py index df4dff70..3a1c9032 100644 --- a/backend/tests/test_eesk_reserve_loader.py +++ b/backend/tests/test_eesk_reserve_loader.py @@ -184,10 +184,36 @@ def test_load_ps_35_220_parse_and_match() -> None: assert first["installed"] == 40.0 assert first["reserve"] == 15.0 assert first["district"] == "Ленинский" - assert first["load_pct"] == 41.0 # доля 0.41 → 41.0% assert first["asof"] == date(2026, 6, 30) +def test_load_ps_35_220_does_not_write_load_percent() -> None: + """#2464-B: степень загрузки НЕ уходит в UPDATE и не попадает в load_index. + + Раньше значение колонки E писалось как + `load_index = COALESCE(load_index, CAST(:load_pct AS text))`. load_index — + категориальная колонка ('open'|'limited'|'closed'|NULL, + data/sql/180_connection_capacity.sql:35): число строкой фронт отбрасывает + в «неизвестно» (classifyLoadIndex), а в power_summary.by_load_index + появлялся бы бакет с именем вроде "41.0". + + На проде не стреляло только потому, что load_index заполнен у всех строк + (open 2741 / limited 346 / closed 329, NULL 0 — замер верификации 13.08), + и COALESCE не проваливался. + """ + from datetime import date + + db = _FakeSession(scalar_value=None, rowcount=1) + ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30)) + + # Комментарии из SQL убираем: слово load_index встречается в пояснении, + # а проверять надо ИСПОЛНЯЕМЫЙ текст, а не прозу вокруг него. + sql_code = "\n".join(line.split("--", 1)[0] for line in str(db.calls[0][0]).splitlines()) + assert "load_index" not in sql_code, sql_code + for _sql, params in db.calls: + assert "load_pct" not in params, params + + def test_load_ps_35_220_unmatched_counted() -> None: """ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем.""" from datetime import date From b0776cd01d88de42959466d6421c8f280d1ebd25 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 14 Aug 2026 06:00:10 +0000 Subject: [PATCH 29/60] =?UTF-8?q?fix(ptica):=20=D0=B1=D0=B0=D0=BD=20=D0=9D?= =?UTF-8?q?=D0=A1=D0=9F=D0=94=20=D0=BF=D0=BE=20WAF=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B3=D0=BB=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=81=D0=B1=D0=BE=D1=80=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=BC=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=20(#2464-A)=20(#2881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/cadastre/bulk_harvest.py | 29 ++++++++- backend/tests/services/test_cadastre_bulk.py | 59 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index 905d15de..8d89b0da 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -30,7 +30,12 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot -from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkServerError +from app.scrapers.nspd_bulk_client import ( + NSPDBulkClient, + NspdBulkRateLimitError, + NspdBulkServerError, + NspdBulkWafError, +) from app.services.cadastre.grid_geometry import generate_grid_click_points, quarter_bbox_3857 logger = logging.getLogger(__name__) @@ -182,6 +187,13 @@ async def harvest_quarter( try: cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id) result.snapshot_requests += 1 + except (NspdBulkWafError, NspdBulkRateLimitError): + # #2464-A: бан IP / исчерпанные ретраи — НЕ «этот cat не дошёл». + # Контракт harvest_quarter (Raises:) обещает пробросить их наверх, + # а голый except ниже их глотал: прогон доходил до status='done' + # с частичными данными. Прод-замер 13.08: 23 job'а, 50 WAF-блоков, + # 0 упавших — то есть бан ни разу не остановил сбор. + raise except Exception as e: logger.warning( "harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s", @@ -279,6 +291,9 @@ async def harvest_quarter( logger.info( "harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count ) + except (NspdBulkWafError, NspdBulkRateLimitError): + # #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст». + raise except Exception as e: logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e) @@ -399,6 +414,18 @@ async def _grid_walk_category( requests += 1 server_errors += 1 continue + except (NspdBulkWafError, NspdBulkRateLimitError): + # #2464-A: 403 WAF — бан IP, а не «этот cell не дошёл». Продолжать + # обход значит углублять бан и дописать в БД ложный нулевой слой. + # Зеркало уже исправленных nspd_bulk_client.get_features_in_bbox_grid + # и nspd_client.get_features_in_bbox_grid (#2464-G). + logger.warning( + "_grid_walk_category: WAF/rate-limit layer=%d quarter=%s cell=%d — прерываем", + layer_id, + quarter, + idx, + ) + raise except Exception as e: # Прочие (сетевые / parse) ошибки одного cell — тоже не валим квартал, # но это НЕ server-side 500 → не учитываем в server_errors (иначе сеть diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index bea84372..d1af98da 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -1393,6 +1393,65 @@ async def test_grid_walk_marks_layer_failed_when_all_cells_500() -> None: assert layer_failed is True +@pytest.mark.asyncio +async def test_grid_walk_reraises_waf_instead_of_swallowing() -> None: + """#2464-A: 403 WAF прерывает обход, а не превращается в «cell не дошёл». + + Контракт harvest_quarter (Raises:) обещает пробросить NspdBulkWafError, но + голый `except Exception` в цикле ячеек его глотал. Прод-замер 13.08: + 23 job'а в cadastre_jobs, суммарно 50 WAF-блоков — и НИ ОДНОГО упавшего + job'а. То есть бан ни разу не остановил сбор, как обещано. + """ + from app.scrapers.nspd_bulk_client import NspdBulkWafError + from app.services.cadastre.bulk_harvest import _grid_walk_category + + db = _mock_db_grid_bbox() + client = AsyncMock() + client.wms_feature_info = AsyncMock(side_effect=NspdBulkWafError("HTTP 403 WAF")) + + with pytest.raises(NspdBulkWafError): + await _grid_walk_category( + db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3 + ) + + +@pytest.mark.asyncio +async def test_grid_walk_reraises_rate_limit() -> None: + """#2464-A: исчерпанные ретраи — тоже не «пустой слой» (caller может retry).""" + from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError + from app.services.cadastre.bulk_harvest import _grid_walk_category + + db = _mock_db_grid_bbox() + client = AsyncMock() + client.wms_feature_info = AsyncMock(side_effect=NspdBulkRateLimitError("429")) + + with pytest.raises(NspdBulkRateLimitError): + await _grid_walk_category( + db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3 + ) + + +@pytest.mark.asyncio +async def test_grid_walk_still_tolerates_network_error_per_cell() -> None: + """Контроль обратной крайности: сетевая ошибка ячейки обход НЕ роняет. + + Зелёный с обеих сторон правки — проверяет, что #2464-A не превратил любое + исключение в отказ квартала. + """ + from app.services.cadastre.bulk_harvest import _grid_walk_category + + db = _mock_db_grid_bbox() + client = AsyncMock() + client.wms_feature_info = AsyncMock(side_effect=OSError("connection reset")) + + upserted, requests, layer_failed = await _grid_walk_category( + db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3 + ) + assert upserted == 0 + assert requests == 9 + assert layer_failed is False, "сетевые сбои НЕ должны поднимать layer_failed" + + @pytest.mark.asyncio async def test_grid_walk_layer_not_failed_when_some_cells_ok() -> None: """Issue #252: если хоть один cell прошёл — layer_failed=False (слой жив, просто пуст).""" From eb0f0827902375e738d6b9f14516bfde7397686e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 14 Aug 2026 15:25:00 +0300 Subject: [PATCH 30/60] =?UTF-8?q?fix(tradein/pdf):=20135-=D0=A4=D0=97=20?= =?UTF-8?q?=D0=B4=D0=B8=D1=81=D0=BA=D0=BB=D0=B5=D0=B9=D0=BC=D0=B5=D1=80=20?= =?UTF-8?q?=D0=B2=20=D1=84=D1=83=D1=82=D0=B5=D1=80=D0=B5=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=B6=D0=B4=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20(=D0=91=D0=BB=D0=BE=D0=BA=204.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running @bottom-center margin-box печатал только мета/wordmark — юр-требование (индикативный расчёт, не отчёт об оценке по 135-ФЗ) отсутствовало на всех 4 страницах. Бюджет высоты подвала = margin-bottom (19mm≈53.9pt) уже был заполнен почти впритык (~51pt) после 42a50cf8 (ровно 4 страницы без пустых). Сжат существующий HUD-хром внутри _page_footer (margin-top 6→4pt, padding-top 8→6pt, line-height мета/wordmark 1.35→1.15, разделитель margin 6pt 0→3pt 0, экономия ~13pt) + добавлен текст дисклеймера отдельным блоком (5pt/line-height 1.15, ~3 строки ≈17pt). Экономии внутри подвала не хватило без деградации до нечитаемого — минимально поднят @page margin-bottom 19mm→21mm (+2mm). Реальный WeasyPrint-рендер (native Pango/cairo) недоступен на Windows-деве — пагинация (риск отката к 5-й пустой странице из-за margin-bottom на всех 4 страницах) не подтверждена локально, арифметика в docstring _page_footer. --- .../app/services/exporters/trade_in_pdf.py | 54 ++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) 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 8e02aae4..ef896560 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -238,6 +238,11 @@ _FS_MD = "10.5pt" # базовый текст (body), значения в та _FS_LG = "13pt" # заголовки страниц (h2, PT Serif) _FS_XL = "16pt" # главный заголовок cover (h1, PT Serif) _FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок) +# Намеренное исключение из 6-шаговой шкалы: running-footer — @page margin-box с +# фиксированной высотой (19mm ≈ 53.9pt), делить с mono-мета-строкой/wordmark +# практически нечем (см. _page_footer). 135-ФЗ дисклеймер (Блок 4.2) должен +# влезать в ~380-450 симв. на каждой странице без пятой пустой страницы. +_FS_XXS = "5pt" # ТОЛЬКО 135-ФЗ футер-дисклеймер (_page_footer) — не переиспользовать # ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ────────────── @@ -528,6 +533,20 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type: ) +# Блок 4.2 юр-требований: должен печататься в подвале КАЖДОЙ страницы отчёта +# (не только cover). Текст утверждён владельцем продукта дословно — не менять +# формулировку без явного запроса. Заведён как модульная константа (не inline +# в _page_footer), чтобы не расползалась по нескольким билдерам страниц. +_PDF_135FZ_FOOTER_NOTICE = ( + "Документ содержит индикативный (ориентировочный) расчёт стоимости объекта, " + "сформированный автоматически сервисом «МЕРА». Не является отчётом об оценке " + "по Федеральному закону № 135-ФЗ и не имеет установленной этим законом " + "юридической силы. Не предназначен для использования при ипотечном " + "кредитовании, в судебных разбирательствах, нотариальных действиях и иных " + "случаях, где законом предусмотрено обязательное проведение независимой оценки." +) + + def _page_footer( brand, # type: ignore[no-untyped-def] report_num: str, @@ -542,7 +561,10 @@ def _page_footer( строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ - хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь + хардкод «МЕРА», white-label остаётся рабочим); строка 3 — 135-ФЗ дисклеймер + (Блок 4.2, _PDF_135FZ_FOOTER_NOTICE) — печатается на КАЖДОЙ странице, т.к. + footer рендерится один раз как running @page margin-box (см. вызов в + generate_trade_in_pdf), а не per-page. Номер версии продукта здесь НЕ дублируется — единственное место вывода версии в PDF — running-header (_page_header → product_version_line()); раньше рядом с wordmark висел decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной @@ -552,6 +574,17 @@ def _page_footer( на офер-странице), которого нет в веб-референсе (там нет пагинации). Не удалён — вынесен приглушённой строкой НАД HUD-баром, чтобы не терять полезную для печатного многостраничного отчёта информацию. + + #footer-height-budget (2026-08-14, Блок 4.2): @bottom-center margin-box + высотой = page margin-bottom (см. _build_css). Добавление 135-ФЗ текста + (~440 симв.) потребовало И сжать существующий HUD-хром (margin-top + 6→4pt, padding-top 8→6pt, line-height мета/wordmark строк 1.35→1.15, + градиент-разделитель margin 6pt 0→3pt 0 — экономия ~13pt), И минимально + поднять @page margin-bottom (19mm→21mm, +2mm/+5.67pt) — сжатия одного + подвала было недостаточно без деградации до нечитаемого. Риск: margin-bottom + режет тело КАЖДОЙ из 4 страниц → потенциальный откат к 5-й почти пустой + странице (регрессия, чинившаяся в 42a50cf8) — реальным рендером + (WeasyPrint/Pango, недоступен на Windows-деве) не подтверждено, см. PR. """ note_html = "" if page_note: @@ -583,18 +616,18 @@ def _page_footer( # тела страницы) и был источником сложности; заменён на простую тонкую # градиентную линию-разделитель между строками meta/wordmark. return f""" -
+
{note_html} -
+ color:{_MUTED_2};line-height:1.15;">
ОТЧЁТ {_html.escape(report_num)} ДАТА {report_date.strftime("%d.%m.%Y")} {valid_until_html}
-
@@ -606,6 +639,11 @@ def _page_footer( {_html.escape(brand.name).upper()}
+
+ {_html.escape(_PDF_135FZ_FOOTER_NOTICE)} +
""" @@ -1962,7 +2000,11 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def] }} @page {{ size: A4; - margin: 25mm 18mm 19mm 18mm; + /* bottom 19mm→21mm (#footer-height-budget, Блок 4.2): +2mm — минимум, + которого не хватило внутри @bottom-center margin-box (высота margin-box + = margin-bottom) даже после сжатия HUD-хрома _page_footer под 135-ФЗ + дисклеймер на каждой странице. См. арифметику в _page_footer(). */ + margin: 25mm 18mm 21mm 18mm; @top-center {{ content: element(runningHeader); vertical-align: bottom; }} @bottom-center {{ content: element(runningFooter); vertical-align: top; }} }} From 1b7e047210e11787d8b0b9cea5d72ebd77697713 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 14 Aug 2026 15:25:58 +0300 Subject: [PATCH 31/60] =?UTF-8?q?feat(tradein/estimate):=20=D1=8E=D1=80-?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=C2=AB=D0=9E=D1=80=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82.=20=D0=9D?= =?UTF-8?q?=D0=B5=20=D1=8F=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BE=D1=84=D0=B8=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=BE=D0=B9.=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блок 4.1 юр-требований владельца (14.08.2026): под диапазоном цены на экране результата обязана быть эта строка. Вынесена в новый модуль lib/legal-copy.ts (SHORT_ESTIMATE_DISCLAIMER, без импортов) — используется и в v2/ResultPanel.tsx (боевой экран /v2), и в HeroSummary.tsx (legacy-контур /trade-in/ui-preview/estimate), первым предложением в уже существующем абзаце-дисклеймере про рыночный разброс. В ResultPanel.tsx подрезаны lineHeight/marginTop/padding соседнего блока, чтобы новая строка не сжимала плитки "ИСТОЧНИКИ ДАННЫХ" на фиксированной высоте артборда. --- .../src/components/trade-in/HeroSummary.tsx | 7 +++++ .../components/trade-in/v2/ResultPanel.tsx | 30 ++++++++++++++----- tradein-mvp/frontend/src/lib/legal-copy.ts | 25 ++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 tradein-mvp/frontend/src/lib/legal-copy.ts diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 652bd91e..7a6b0b9f 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -5,6 +5,9 @@ /** * HeroSummary — Секция 1 «Сводка» из mockup tradein.html. * Показывает медиану + достоверность CV + параметры объекта + 2 ценовых бара (объявления / сделки). + * + * hero-duo__hint под диапазоном сделок несёт юр-требование владельца от + * 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER — см. `lib/legal-copy.ts`. */ import { useState } from "react"; import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in"; @@ -12,6 +15,7 @@ import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { sourceDot, sourceLabel } from "@/lib/source-registry"; import { dealsAsOfLabel } from "@/lib/rosreestr"; +import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy"; import { HeroTransparency } from "./HeroTransparency"; interface Props { @@ -478,6 +482,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
)}

+ {/* Блок 4.1 (владелец, 14.08.2026) — юр-строка идёт первым + предложением абзаца-дисклеймера, не отдельным блоком. */} + {SHORT_ESTIMATE_DISCLAIMER}{" "} Диапазон отражает разброс цен по рынку, а не погрешность оценки. {/* #2209: честное раскрытие точности — замеренное покрытие бэктестом по реальным ДКП Росреестра (2026-07: n=276, coverage 81.5%). diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index e2d50e8d..80895489 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -1,9 +1,14 @@ "use client"; +/** + * §1.2-дисклеймер под ценовыми карточками несёт юр-требование владельца от + * 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER — см. `lib/legal-copy.ts`. + */ import { Fragment } from "react"; import type { Ref } from "react"; import { tokens } from "./tokens"; import type { ResultPanelData } from "./mappers"; +import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy"; const { accent, @@ -557,20 +562,29 @@ export default function ResultPanel({ })} - {/* §1.2 честный разброс: диапазоны цен = рыночный спред, НЕ погрешность - оценки. Ставим сразу под ценовыми карточками, чтобы широкий диапазон - не читался как «неуверенность расчёта». */} + {/* §1.2 честный разброс + юр-дисклеймер (владелец, 14.08.2026, блок 4.1): + SHORT_ESTIMATE_DISCLAIMER идёт первым предложением, "разброс цен" + — вторым, один абзац (три отдельных оговорки подряд читались бы + как шум). Ставим сразу под ценовыми карточками, чтобы широкий + диапазон не читался как «неуверенность расчёта». + Компенсация высоты: этот блок стал на строку выше, чем до + SHORT_ESTIMATE_DISCLAIMER (776px средняя колонка узкая для двух + предложений в одну строку) — lineHeight/marginTop подрезаны, и + padding у "ranges + radar" box ниже уменьшен по вертикали, чтобы + не сжимать плитки "ИСТОЧНИКИ ДАННЫХ" (flex:1 ниже). Высота + артборда (1536×1024, v2/page.tsx) не менялась. */}

- Диапазоны показывают разброс цен на рынке, а не погрешность оценки. + {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, + а не погрешность оценки.
{/* ranges + radar */} @@ -581,7 +595,9 @@ export default function ResultPanel({ backdropFilter: "blur(6px)", border: `1px solid ${line2}`, borderRadius: 8, - padding: "18px 20px", + // было "18px 20px" — вертикаль подрезана на 4px сверху/снизу, см. + // комментарий к дисклеймеру выше (компенсация высоты под #4.1). + padding: "14px 20px", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24, diff --git a/tradein-mvp/frontend/src/lib/legal-copy.ts b/tradein-mvp/frontend/src/lib/legal-copy.ts new file mode 100644 index 00000000..0ba3680e --- /dev/null +++ b/tradein-mvp/frontend/src/lib/legal-copy.ts @@ -0,0 +1,25 @@ +/** + * Юридические короткие оговорки для экранов расчёта «Меры». + * + * Источник: юр-требование владельца от 14.08.2026 (блок 4.1) — прямо под + * диапазоном цены на экране результата обязана быть строка «Ориентировочный + * расчёт. Не является официальной оценкой.». + * + * Модуль НАМЕРЕННО без импортов (кроме типов) — используется и из закрытого + * контура (`components/trade-in/v2/ResultPanel.tsx`, + * `components/trade-in/HeroSummary.tsx`), и потенциально из `/mera-public`, + * где действует isolation-guard + * (`scripts/check-mera-public-isolation.mjs`) — любой транзитивный импорт + * закрытого контура (useMe / lib/api / lib/sessionId / isPathAllowed) в + * публичный бандл провалит проверку. + * + * В репозитории уже есть соседние, но НЕ идентичные дисклеймеры — не путать + * и не плодить пятый/шестой литерал с той же сутью: + * - `src/app/mera-public/content.ts` → DELIVERABLES_DISCLAIMER (135-ФЗ) + * - `src/app/mera-public/_components/SiteFooter.tsx:109` + * - `src/app/oferta/page.tsx:253` + * - `backend/app/services/trade_in_pdf.py` (версия для PDF-отчёта) + */ + +export const SHORT_ESTIMATE_DISCLAIMER = + "Ориентировочный расчёт. Не является официальной оценкой."; From 91732d1acbecc7985bc7bff24d8dc7d784b3e8cd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 14 Aug 2026 15:28:35 +0300 Subject: [PATCH 32/60] =?UTF-8?q?feat(mera):=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D1=88=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA=D0=B8=20=D0=B8=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=8E=D1=80-=D1=82=D1=80=D0=B5=D0=B1?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20(=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Юр-документ владельца от 14.08.2026, блок 3: верхний дескриптор и заголовок вкладки должны уводить сервис от слова, отсылающего к отчёту оценщика по 135-ФЗ, и явно называть основание расчёта — рыночные данные. - Шапка лэндинга: «Оценка вторичного жилья · <регион>» → «Оценка вторичного жилья по рыночным данным · <регион>». Слово «МЕРА» остаётся вордмарком слева (у него свой letter-spacing), строка складывается из двух узлов; регион по-прежнему из REGION_NAME, не литералом. - Заголовок вкладки: «МЕРА — оценка квартиры на вторичном рынке» → «Мера · расчёт стоимости квартиры по рыночным данным». H1 первого экрана («Сколько на самом деле стоит ваша квартира») НЕ меняется — решение владельца: он не заявляет ничего про официальную оценку, а продающий заголовок терять незачем. Заголовки трёх юридических подстраниц живут по своему шаблону «<Документ> — МЕРА» и не затронуты. Проверено: tsc --noEmit, next lint, mera-public isolation guard (18 файлов). --- .../src/app/mera-public/_components/SiteHeader.tsx | 10 +++++++++- tradein-mvp/frontend/src/app/mera-public/layout.tsx | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx index 4ec23dcc..a4c4d29a 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx @@ -4,6 +4,14 @@ * Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" → "/v2", * то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение * (и дальше на /login). Пока публичная страница одна, вордмарк — просто текст. + * + * ФОРМУЛИРОВКА ДЕСКРИПТОРА (14.08.2026, юр-документ владельца, блок 3). Строка + * шапки обязана читаться как «МЕРА · Оценка вторичного жилья по рыночным данным · + * <регион>»: слова «по рыночным данным» — то, чем сервис отличается от оценки по + * 135-ФЗ, и юрист попросил, чтобы это стояло в самом заметном дескрипторе, а не + * только в дисклеймерах внизу. «МЕРА» приходит из вордмарка ниже (у него свой + * letter-spacing), поэтому в headerTag её нет — строка складывается из двух узлов. + * Регион — из REGION_NAME, не строкой (правило content.ts). */ import { REGION_NAME } from "../content"; @@ -18,7 +26,7 @@ export function SiteHeader() { МЕРА

- Оценка вторичного жилья · {REGION_NAME} + Оценка вторичного жилья по рыночным данным · {REGION_NAME}

diff --git a/tradein-mvp/frontend/src/app/mera-public/layout.tsx b/tradein-mvp/frontend/src/app/mera-public/layout.tsx index d3266fbb..a690d775 100644 --- a/tradein-mvp/frontend/src/app/mera-public/layout.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/layout.tsx @@ -46,8 +46,16 @@ const plexMono = IBM_Plex_Mono({ display: "swap", }); +/** + * ЗАГОЛОВОК ВКЛАДКИ (14.08.2026, юр-документ владельца, блок 3). «Расчёт + * стоимости», а не «оценка квартиры»: заголовок вкладки — второе после шапки + * место, где формулировка читается как заявление о характере услуги, и юрист + * попросил увести её от слова, отсылающего к отчёту оценщика по 135-ФЗ. + * Заголовки трёх юридических подстраниц (оферта / возврат / ПДн) живут по + * своему шаблону «<Документ> — МЕРА» и этой правкой не затрагиваются. + */ export const metadata: Metadata = { - title: "МЕРА — оценка квартиры на вторичном рынке", + title: "Мера · расчёт стоимости квартиры по рыночным данным", description: `Узнайте рыночную стоимость квартиры по адресу: сделки Росреестра и объявления площадок. ${REGION_NAME}.`, robots: { index: false, follow: false }, }; From 74e7969ef90115828531b1b9b82549ace853afc5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 14 Aug 2026 15:29:49 +0300 Subject: [PATCH 33/60] =?UTF-8?q?fix(tradein/lead):=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=81?= =?UTF-8?q?=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D1=83=20=D0=9F=D0=94=D0=BD=20?= =?UTF-8?q?=D0=B2=20=D1=87=D0=B5=D0=BA=D0=B1=D0=BE=D0=BA=D1=81=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit РКН/владелец: рядом с чекбоксом согласия должна быть ссылка на сам документ политики обработки ПДн, а не упоминание закона. Чекбокс в LeadForm.tsx (v2, живой /trade-in/v2) теперь линкует "Политикой обработки персональных данных" на /mera-public/privacy (target=_blank, чтобы не терять заполненную форму). Путь вынесен в новый src/lib/legal-copy.ts (модуль без импортов) — content.ts ре-экспортирует оттуда, чтобы B2B-виджет не тянул B2C-лэндинг-модуль целиком. _CONSENT_TEXT_SNAPSHOT/_CONSENT_POLICY_VERSION в lead.py обновлены под новый плоский текст и дату утверждения политики (PRIVACY_APPROVAL: 2026-08-13). test_consent_text_frontend_sync.py: экстрактор теперь снимает JSX-теги/{" "} спейсеры перед сравнением (иначе сломался бы на разметке ссылки) + новый тест держит _CONSENT_POLICY_VERSION в синхроне с PRIVACY_APPROVAL из content.ts, чтобы версия не расходилась молча с редакцией документа. Легаси-дубль в HeroTransparency.tsx (недостижим с живого роута) — текст приведён в соответствие без ссылки: компонент не смонтирован нигде, и нет теста, который держал бы там ссылку в актуальном состоянии. --- tradein-mvp/backend/app/api/v1/lead.py | 22 ++- .../tests/test_consent_text_frontend_sync.py | 127 ++++++++++++++++-- .../frontend/src/app/mera-public/content.ts | 5 +- .../components/trade-in/HeroTransparency.tsx | 11 +- .../src/components/trade-in/v2/LeadForm.tsx | 12 +- tradein-mvp/frontend/src/lib/legal-copy.ts | 26 ++++ 6 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 tradein-mvp/frontend/src/lib/legal-copy.ts diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 6ac5ba07..a0eb307a 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -51,16 +51,24 @@ _PHONE_MAX_DIGITS = 15 # Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится # per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась # только в audit-лог (#2497 TODO, теперь закрыт). -_CONSENT_POLICY_VERSION = "2026-07" +# +# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/ +# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» → +# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию +# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует +# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет +# это соответствие автоматически, так что рассинхронизация здесь падает в CI. +_CONSENT_POLICY_VERSION = "2026-08-13" -# Снимок точного текста согласия, который видит пользователь при отправке лида. -# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/ -# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять -# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки -# будут нести устаревший snapshot под новой version-меткой. +# Снимок точного текста согласия, который видит пользователь при отправке лида +# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом +# с этой фразой). Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/ +# src/components/trade-in/v2/LeadForm.tsx) — если текст меняется, здесь нужно +# поднять _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые +# строки будут нести устаревший snapshot под новой version-меткой. _CONSENT_TEXT_SNAPSHOT = ( "Согласен(-на) на обработку персональных данных в соответствии с " - "Федеральным законом «О персональных данных» № 152-ФЗ" + "Политикой обработки персональных данных" ) diff --git a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py index b87af5ae..b82bda69 100644 --- a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py +++ b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py @@ -15,8 +15,19 @@ WHAT: 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. + lines; the DOM-rendered text collapses that to single spaces). The label + now wraps a `` ("Политикой обработки персональных данных" is a + clickable link to the actual policy document, RKN/owner requirement -- + the extractor strips JSX tags AND `{" "}` expression-spacers, keeping only + the human-readable text, so the comparison stays a FLAT string on both + sides). If someone edits ONE side without the other, this test fails. + + A second test (`test_consent_policy_version_matches_privacy_approval_date`) + guards the OTHER half of the same drift class found during triage: nothing + was checking that _CONSENT_POLICY_VERSION actually points at the privacy + policy edition it claims to (PRIVACY_APPROVAL in mera-public/content.ts). + Bumping the policy text without bumping the version tag (or vice versa) + would silently mislabel every lead's proof-of-consent snapshot. NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet @@ -34,25 +45,71 @@ from pathlib import Path os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +_REPO_ROOT = Path(__file__).resolve().parents[2] + _FRONTEND_LEAD_FORM = ( - Path(__file__).resolve().parents[2] - / "frontend" - / "src" - / "components" - / "trade-in" - / "v2" - / "LeadForm.tsx" + _REPO_ROOT / "frontend" / "src" / "components" / "trade-in" / "v2" / "LeadForm.tsx" ) +_FRONTEND_LEGAL_CONTENT = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts" + +# Родительный падеж месяцев, как их пишет владелец в content.ts ("13 августа 2026 г."). +_RU_MONTHS_GENITIVE = { + "января": 1, + "февраля": 2, + "марта": 3, + "апреля": 4, + "мая": 5, + "июня": 6, + "июля": 7, + "августа": 8, + "сентября": 9, + "октября": 10, + "ноября": 11, + "декабря": 12, +} + def _extract_span_text(tsx_source: str) -> str: """Pull the text content of the (single) ... in LeadForm.tsx, whitespace-normalised the same way a browser collapses JSX text-node whitespace when rendering (multiple lines/indentation -> single spaces). + + The span may contain nested JSX markup (e.g. a wrapping part of the + label, and a `{" "}` expression-spacer forcing a real space between a text + node and the link on the next source line -- plain JSX whitespace between + a text node and a tag on separate lines collapses to NOTHING, not a + space, so LeadForm.tsx needs that explicit spacer for correct rendering). + Both are stripped here so the comparison is against the flat, human- + readable text a user actually sees -- not the markup. """ - match = re.search(r"\s*(.*?)\s*", tsx_source, re.DOTALL) + match = re.search(r"(.*?)", 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() + inner = match.group(1) + inner = re.sub(r"\{\s*[\"']\s*[\"']\s*\}", " ", inner) # {" "} spacer -> real space + inner = re.sub(r"\{/\*.*?\*/\}", " ", inner, flags=re.DOTALL) # JSX comments + inner = re.sub(r"<[^>]+>", "", inner) # strip remaining JSX tags (e.g. , ) + return re.sub(r"\s+", " ", inner).strip() + + +def _extract_privacy_approval_iso_date(content_ts_source: str) -> str: + """Pull the "DD YYYY" date out of PRIVACY_APPROVAL in + mera-public/content.ts and return it as an ISO "YYYY-MM-DD" string. + + PRIVACY_APPROVAL ("приказом директора № 1 от 13 августа 2026 г.") is the + order that approves the actual privacy-policy EDITION the consent + checkbox links to (/mera-public/privacy) -- it is the correct source of + truth for _CONSENT_POLICY_VERSION, as opposed to LEGAL_DOCS_REVISION + (which dates the offer + refund-policy documents, a different pair). + """ + match = re.search(r'PRIVACY_APPROVAL\s*=\s*"([^"]+)"', content_ts_source) + assert match is not None, "PRIVACY_APPROVAL constant not found in mera-public/content.ts" + date_match = re.search(r"(\d{1,2})\s+([а-яё]+)\s+(\d{4})", match.group(1)) + assert date_match is not None, f"no RU date found in PRIVACY_APPROVAL: {match.group(1)!r}" + day, month_name, year = date_match.groups() + month = _RU_MONTHS_GENITIVE.get(month_name) + assert month is not None, f"unknown RU month name in PRIVACY_APPROVAL: {month_name!r}" + return f"{year}-{month:02d}-{int(day):02d}" def test_frontend_lead_form_exists() -> None: @@ -78,6 +135,29 @@ def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None: ) +def test_consent_policy_version_matches_privacy_approval_date() -> None: + """Guards the other half of the same drift class as the test above: + _CONSENT_POLICY_VERSION must point at the privacy-policy EDITION it + claims to (PRIVACY_APPROVAL in mera-public/content.ts), not just be some + unrelated date bumped by hand. A silent mismatch here would mislabel + every lead's proof-of-consent snapshot with the wrong policy edition.""" + from app.api.v1.lead import _CONSENT_POLICY_VERSION + + assert _FRONTEND_LEGAL_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_LEGAL_CONTENT}" + expected_version = _extract_privacy_approval_iso_date( + _FRONTEND_LEGAL_CONTENT.read_text(encoding="utf-8") + ) + + assert _CONSENT_POLICY_VERSION == expected_version, ( + "app/api/v1/lead.py._CONSENT_POLICY_VERSION does not match the privacy-policy " + "edition date derived from PRIVACY_APPROVAL in frontend/src/app/mera-public/" + "content.ts. Bump _CONSENT_POLICY_VERSION to the new edition date whenever " + "PRIVACY_APPROVAL changes (or vice versa).\n" + f" _CONSENT_POLICY_VERSION: {_CONSENT_POLICY_VERSION!r}\n" + f" PRIVACY_APPROVAL date: {expected_version!r}" + ) + + def test_extract_span_text_helper_is_whitespace_insensitive() -> None: """Sanity check on the extraction helper itself, independent of the real file.""" sample = """ @@ -87,3 +167,28 @@ def test_extract_span_text_helper_is_whitespace_insensitive() -> None: """ assert _extract_span_text(sample) == "Line one Line two" + + +def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None: + """Sanity check: a wrapping part of the label (plus the {" "} + spacer JSX needs to force a real space before it) must collapse to plain + text, exactly like a browser renders it -- this is the shape LeadForm.tsx + actually uses today for the policy-document link.""" + sample = """ + + Согласен(-на) на обработку персональных данных в соответствии с{" "} + + Политикой обработки персональных данных + + + """ + assert _extract_span_text(sample) == ( + "Согласен(-на) на обработку персональных данных в соответствии с " + "Политикой обработки персональных данных" + ) + + +def test_extract_privacy_approval_iso_date_helper() -> None: + """Sanity check on the RU-date extraction helper, independent of the real file.""" + sample = 'export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";' + assert _extract_privacy_approval_iso_date(sample) == "2026-08-13" diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index a2269a15..c4ab622d 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -157,7 +157,10 @@ export const LEGAL_ENTITY: { * ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой * странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже). */ -export const PRIVACY_PATH = "/mera-public/privacy"; +// PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же +// путь нужен B2B trade-in виджетам (чекбокс согласия в v2 LeadForm.tsx), которым +// нежелательно тянуть весь этот B2C-лэндинг-модуль (см. legal-copy.ts). +export { PRIVACY_PATH } from "@/lib/legal-copy"; export const OFFER_PATH = "/mera-public/oferta"; export const REFUND_PATH = "/mera-public/refund"; diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx index fc2d8906..d4dfd991 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -250,8 +250,15 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { disabled={leadMutation.isPending} /> - Согласен(-на) на обработку персональных данных в соответствии с Федеральным - законом «О персональных данных» № 152-ФЗ + {/* Легаси-дерево, недостижимое с живого /trade-in/v2 (см. LeadForm.tsx + header-комментарий) — текст держим в синхроне с v2-чекбоксом, чтобы + формулировки не разъезжались, если этот компонент когда-нибудь + вернётся в строй. Кликабельную ссылку здесь намеренно не добавляем: + компонент нигде не смонтирован, никакой пользователь его не видит, + и нет теста, который держал бы такую ссылку в актуальном состоянии + (в отличие от LeadForm.tsx — см. test_consent_text_frontend_sync.py). */} + Согласен(-на) на обработку персональных данных в соответствии с Политикой + обработки персональных данных {phoneInvalid && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx index 3aeb5484..49c7c480 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx @@ -23,8 +23,10 @@ // explanation for whichever field blocked it. import { useState } from "react"; import type { CSSProperties, FormEvent } from "react"; +import Link from "next/link"; import { tokens } from "./tokens"; import { useCreateLeadMutation } from "@/lib/trade-in-api"; +import { PRIVACY_PATH } from "@/lib/legal-copy"; const { accent, @@ -306,8 +308,14 @@ export function LeadForm({ estimateId }: LeadFormProps) { style={{ marginTop: 2, cursor: "inherit", flex: "0 0 auto" }} /> - Согласен(-на) на обработку персональных данных в соответствии с - Федеральным законом «О персональных данных» № 152-ФЗ + Согласен(-на) на обработку персональных данных в соответствии с{" "} + {/* target="_blank" — клик по ссылке не должен сбрасывать уже заполненную + форму заявки (телефон + чекбокс) в текущей вкладке. Ссылка вложена + в diff --git a/tradein-mvp/frontend/src/lib/legal-copy.ts b/tradein-mvp/frontend/src/lib/legal-copy.ts new file mode 100644 index 00000000..2f625cf4 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/legal-copy.ts @@ -0,0 +1,26 @@ +/** + * Юридические константы, разделяемые между B2C-лэндингом + * (`src/app/mera-public/content.ts`) и B2B trade-in виджетами + * (`src/components/trade-in/**`, включая v2 `LeadForm.tsx`). + * + * ПОЧЕМУ отдельный модуль: v2-компонент результата оценки (B2B-экран, + * пилоты/менеджеры) не должен тянуть `mera-public/content.ts` целиком — + * тот модуль набит B2C-лэндинг-специфичными константами (`LEGAL_ENTITY`, + * `PUBLIC_ESTIMATE_ENABLED`, банковские реквизиты и т.д.), появление + * которых в графе импортов B2B-экрана было бы случайной и нежелательной + * связью двух разных поверхностей продукта. Вместо этого путь к странице + * политики ПДн живёт здесь — в модуле БЕЗ импортов, который могут + * безопасно использовать обе стороны. + * + * `content.ts` ре-экспортирует `PRIVACY_PATH` отсюда (не дублирует + * литерал) — единственный источник истины для пути. + */ + +/** + * Путь страницы политики обработки персональных данных внутри Next-приложения + * (то, что понимает `next/link` с учётом `basePath=/trade-in` в проде). + * Публично та же страница доступна коротким адресом meraocenka.ru/privacy + * (см. `PUBLIC_PRIVACY_URL` в `mera-public/content.ts`), но для ссылки из + * чекбоксов согласия внутри приложения нужен именно внутренний путь. + */ +export const PRIVACY_PATH = "/mera-public/privacy"; From f3205d3ba1618c70cb92d0ff8da75f214f8461f8 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 15 Aug 2026 13:44:22 +0000 Subject: [PATCH 34/60] =?UTF-8?q?fix(tradein/cian):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8C=20=D1=81=D0=B1=D0=BE=D1=80=20=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=87=D0=BA=D0=B8=20=E2=80=94=20sweep?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D1=81=D1=8B=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=20=D0=B2=D1=81=D1=91=20=D0=BD=D0=B0=D0=B9=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20(#2880)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ules_wave2_cian_newbuilding_only_false.sql | 52 +++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + 2 files changed, 53 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql diff --git a/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql b/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql new file mode 100644 index 00000000..5f1078ed --- /dev/null +++ b/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql @@ -0,0 +1,52 @@ +-- 263_scrape_schedules_wave2_cian_newbuilding_only_false.sql +-- Дописывает "newbuilding_only": false в default_params 40 cian-строк wave 2 (262_). +-- +-- ПОЧЕМУ. Прогон первого включённого города области показал, что sweep отрабатывает +-- «успешно», но не сохраняет НИЧЕГО: +-- +-- cian-sweep run_id=3884 anchor Новоуральск центр: +-- SERP fetched=84 nb_kept=0 dropped_secondary=84 ins=0 upd=0 +-- cian-sweep run_id=3884 done: anchors=1/1 lots=84 (ins=0/upd=0) ... errors=0 +-- +-- 84 лота найдено и все 84 отброшено как вторичка, статус прогона при этом done. +-- +-- Причина: scraper_kit.orchestration.scheduler (_job_cian_city_sweep) читает +-- newbuilding_only=bool(params.get("newbuilding_only", True)) +-- то есть дефолт — True. Сид 179_ (wave 1) ключ проставляет явно (false), а 262_ +-- (wave 2) его потерял. Мера оценивает ВТОРИЧКУ — estimator отбирает аналоги с +-- (listing_segment IS NULL OR listing_segment = 'vtorichka'), — поэтому режим +-- «только новостройки» для этих строк бессмыслен: сбор идёт, данные выбрасываются. +-- +-- ЗАТРАГИВАЕТ ТОЛЬКО cian. У avito/yandex такого параметра нет ни в 179_, ни в 262_ +-- (проверено сравнением default_params wave-1 и wave-2 на проде) — их не трогаем. +-- +-- ПОБОЧНАЯ НАХОДКА: под гейт попадает 41 строка, а не 40. Лишняя — +-- `cian_city_sweep_verkhnyaya_pyshma` из wave 1, ВКЛЮЧЁННАЯ и работающая в проде: +-- 179_ проставил newbuilding_only не всем своим городам. Последствия на живых данных: +-- +-- Верхняя Пышма (ключа нет): cian 184 активных → вторички 3, новостроек 181 +-- Первоуральск (ключ есть): cian 336 активных → вторички 308 +-- +-- То есть по Верхней Пышме Циан давал оценщику 3 пригодных объявления вместо ~300 — +-- сбор шёл, статус зелёный, данные молча выбрасывались. Эта миграция чинит и её. + + +-- +-- Идемпотентность: WHERE-гейт `NOT (default_params ? 'newbuilding_only')` — миграция +-- дописывает ключ только там, где его нет. Повторный прогон — no-op, и она никогда +-- не перезатрёт значение, выставленное позже вручную оператором. +-- +-- ЗАВИСИМОСТИ: 262_ (сами строки), 052_scrape_schedules.sql (таблица). + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +UPDATE scrape_schedules +SET default_params = default_params || '{"newbuilding_only": false}'::jsonb, + updated_at = NOW() +WHERE source LIKE 'cian\_city\_sweep\_%' + AND default_params ? 'city' + AND NOT (default_params ? 'newbuilding_only'); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 4b98c273..483e25df 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -251,3 +251,4 @@ 260_houses_drop_has_panorama.sql 261_listings_search_mv_drop_placeholder_columns.sql 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql +263_scrape_schedules_wave2_cian_newbuilding_only_false.sql From ddb76a137bd22fe5eae021f9931a304aadb0a768 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 16:53:25 +0300 Subject: [PATCH 35/60] =?UTF-8?q?chore(ops):=20=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D1=8C=20=D1=83?= =?UTF-8?q?=D1=82=D0=B5=D1=87=D0=BA=D0=B8=20docker-=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D0=B2=20+=20=D0=B5=D0=B6=D0=B5=D0=BD=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=83=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Диск был занят на 76% (110 из 145 ГБ). Разбор: 201 том-сирота на 12.6 ГБ — 125 анонимных (каталоги данных PostgreSQL от тестовых прогонов CI) и 76 окружений задач Forgejo Actions. Прод-данных среди них нет ни одного. Корневая причина: ci.yml и ci-tradein.yml поднимают свой postgres и снимают его через `docker rm -f` БЕЗ `-v`. Контейнер уходит, анонимный том с данными остаётся сиротой — по одному на каждый прогон CI. - ci.yml / ci-tradein.yml: `docker rm -f "$CI_PG"` → `docker rm -fv` (4 места). В deploy-*.yml тот же вызов применяется к БОЕВЫМ контейнерам — туда -v добавлять нельзя, снесло бы тома с данными прода. Не тронуто. - ops/docker-prune.sh — страховка на то, что runner не убрал за собой. Удаляет: остановленные контейнеры старше 24ч, висячие образы старше 7 суток и тома-сироты ТОЛЬКО двух известных форм (64-символьный hex и FORGEJO-ACTIONS-TASK-*). Именованные тома не трогаются никогда — голый `docker volume prune` такой разницы не делает, поэтому здесь не используется. Порядок важен: контейнеры → образы → тома, иначе освободившиеся после контейнеров тома останутся до следующего запуска (на этом я и споткнулся при ручной чистке — после prune осталось ещё 78 сирот). Проверено на проде: DRY_RUN, затем боевой прогон, затем повторный — no-op. Тома 220 → 15, все используются, освобождать нечего. Диск 76% → 67%. Cron поставлен: вс 04:00 UTC (свободный слот рядом с бэкапами). --- .forgejo/workflows/ci-tradein.yml | 4 +- .forgejo/workflows/ci.yml | 4 +- ops/docker-prune.sh | 102 ++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 ops/docker-prune.sh diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 522cba14..0fdc2732 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -116,7 +116,7 @@ jobs: # бы, а тесты всё равно скипались. run: | set -u - docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true docker run -d --name "$CI_PG" \ -e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \ postgis/postgis:16-3.4 @@ -221,7 +221,7 @@ jobs: # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. if: always() working-directory: . - run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 1bcf6635..eccedb9e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -142,7 +142,7 @@ jobs: # здесь не нужен вовсе, в отличие от tradein-лэйна. run: | set -u - docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true docker run -d --name "$CI_PG" \ -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ postgres:16 @@ -279,7 +279,7 @@ jobs: working-directory: . run: | echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})" - docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true echo "### шаг «Снести тестовый Postgres» закончился успешно" frontend-tests: diff --git a/ops/docker-prune.sh b/ops/docker-prune.sh new file mode 100644 index 00000000..31ca32d1 --- /dev/null +++ b/ops/docker-prune.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Периодическая уборка docker-мусора на прод-VM. +# +# ЗАЧЕМ. 2026-08-15 диск был занят на 76% (110 из 145 ГБ). Разбор показал 201 +# том-сироту на 12.6 ГБ: 125 анонимных — каталоги данных PostgreSQL от тестовых +# прогонов CI, 76 — окружения задач Forgejo Actions. Прод-данных среди них не +# было ни одного. +# +# Корневая причина анонимных томов устранена отдельно: ci.yml и ci-tradein.yml +# снимали свой postgres через `docker rm -f` БЕЗ `-v`, поэтому контейнер уходил, +# а его том оставался. Теперь там `docker rm -fv`. Этот скрипт — страховка: он +# подбирает то, что runner не убрал за собой, и то, что накопилось раньше. +# +# ЧТО ИМЕННО УДАЛЯЕТСЯ (осознанно консервативно): +# - остановленные контейнеры старше 24ч; +# - висячие (dangling) образы старше 7 суток; +# - тома-сироты ТОЛЬКО двух известных форм: 64-символьный hex (анонимные) и +# FORGEJO-ACTIONS-TASK-*. Именованные тома со смыслом (gendesign_postgres_data, +# tradein-postgres-data, *_caddy_*, couchdb, redis и любые будущие) не трогаются +# НИКОГДА — даже если в моменте оказались отцеплены. Голый `docker volume prune` +# такой разницы не делает, поэтому здесь он намеренно не используется. +# +# Usage (cron на прод-VM; `bash <путь>`, а не голый путь — тогда снятый +x не ломает). +# Лог в /tmp — как у соседних записей в том же crontab (backup.sh, backfill'ы): +# 0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1 +# +# Воскресенье 04:00 UTC — свободный слот: рядом 03:30 backup.sh, 04:30 backup +# tradein, 05:00+ backfill'ы. +# +# Раз в неделю достаточно: после устранения корневой причины (docker rm -fv в CI) +# копятся только тома runner'а. DRY_RUN=1 — показать, что удалится, не трогая. + +set -euo pipefail + +DRY_RUN="${DRY_RUN:-0}" +STOPPED_AGE="${STOPPED_AGE:-24h}" +IMAGE_AGE="${IMAGE_AGE:-168h}" + +log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; } + +disk_used_pct() { df --output=pcent / | tail -1 | tr -dc '0-9'; } + +before_pct="$(disk_used_pct)" +log "старт: диск занят ${before_pct}%" + +if [[ "$DRY_RUN" == "1" ]]; then + log "DRY_RUN=1 — только показываю" +fi + +# ── 1. остановленные контейнеры ─────────────────────────────────────────────── +if [[ "$DRY_RUN" == "1" ]]; then + # `until` поддерживает только `prune`, у `ls` его нет («invalid filter 'until'»), + # поэтому в dry-run считаем ВСЕ остановленные — это верхняя оценка. + log "остановленных контейнеров всего (удалятся только старше ${STOPPED_AGE}): \ +$(docker container ls -aq --filter "status=exited" | wc -l)" +else + log "контейнеры: $(docker container prune -f --filter "until=${STOPPED_AGE}" \ + 2>&1 | tail -1)" +fi + +# ── 2. висячие образы ───────────────────────────────────────────────────────── +if [[ "$DRY_RUN" == "1" ]]; then + log "висячих образов: $(docker image ls -qf dangling=true | wc -l)" +else + log "образы: $(docker image prune -f --filter "until=${IMAGE_AGE}" 2>&1 | tail -1)" +fi + +# ── 3. тома-сироты известных форм ───────────────────────────────────────────── +# Отбираем ПОИМЁННО, а не через `docker volume prune`: тот снёс бы любой +# отцепленный именованный том, включая боевой, если контейнер в моменте пересоздаётся. +mapfile -t candidates < <( + docker volume ls -qf dangling=true \ + | grep -E '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true +) + +skipped="$(docker volume ls -qf dangling=true \ + | grep -vE '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true)" +if [[ -n "$skipped" ]]; then + log "ПРОПУЩЕНЫ (именованные, руками): $(echo "$skipped" | tr '\n' ' ')" +fi + +if [[ "${#candidates[@]}" -eq 0 ]]; then + log "томов-сирот известных форм нет" +elif [[ "$DRY_RUN" == "1" ]]; then + log "томов к удалению: ${#candidates[@]}" +else + removed=0 + for v in "${candidates[@]}"; do + if docker volume rm "$v" >/dev/null 2>&1; then + removed=$((removed + 1)) + fi + done + log "томов удалено: ${removed} из ${#candidates[@]}" +fi + +after_pct="$(disk_used_pct)" +log "готово: диск занят ${after_pct}% (было ${before_pct}%)" + +# Сигнал в лог, если места всё равно мало — повод посмотреть глазами. +if [[ "$after_pct" -ge 85 ]]; then + log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно" +fi From c0782a8c4c9d86a1c74a8cea04b0a67d626e62a2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 17:14:00 +0300 Subject: [PATCH 36/60] =?UTF-8?q?chore(deploy):=20=D1=82=D1=80=D0=B8=D0=B3?= =?UTF-8?q?=D0=B3=D0=B5=D1=80=D0=B8=D1=82=D1=8C=20=D0=B4=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D1=83=20ops/docker-prune.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скрипт уборки docker-мусора (#2887) исполняется на прод-VM по cron из /opt/gendesign/ops/. Файлы туда попадают единственным путём — шагом `git reset --hard origin/main` внутри deploy.yml. Но paths-фильтр deploy.yml перечисляет подпути ops/ поимённо, а не ops/**. Поэтому мерж #2887 деплой НЕ запустил: скрипт остался в main, на VM его не было, а установленный cron указывал в пустоту. Правки скрипта и дальше доезжали бы только случайно — со следующим чужим коммитом в backend/. Ровно этот же баг уже ловили на ops/db-bootstrap/** — там рядом стоит комментарий с той же формулировкой. Добавляю ops/docker-prune.sh по образцу и фиксирую грабли в rules/deploy.md, чтобы следующий исполняемый файл в ops/ не наступил на них третий раз. --- .claude/rules/deploy.md | 3 ++- .forgejo/workflows/deploy.yml | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.claude/rules/deploy.md b/.claude/rules/deploy.md index c01e72de..6b0077bb 100644 --- a/.claude/rules/deploy.md +++ b/.claude/rules/deploy.md @@ -25,7 +25,8 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod ## Path triggers (Forgejo Actions, `.forgejo/workflows/`) -- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack) +- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `ops/db-bootstrap/**`, `ops/docker-prune.sh`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack) +- ⚠️ `ops/**` целиком **не** триггерит — только перечисленные подпути. Любой новый файл в `ops/`, который исполняется на VM (cron / шаг деплоя), надо добавлять в `paths:` явно, иначе он не доедет до `/opt/gendesign` и будет молча исполняться в старой версии - trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all) - `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md` → `.forgejo/workflows/deploy-obsidian.yml` - `docs/**` alone → НЕ триггерит деплой diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index ebdcc75f..604e28ee 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -20,6 +20,11 @@ on: # деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы # до прода до следующего чужого коммита в backend/. - "ops/db-bootstrap/**" + # То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM + # по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard` + # шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами + # исполнял бы старую версию — молча и без единого сигнала. + - "ops/docker-prune.sh" workflow_dispatch: concurrency: From 31c8ac4c5f170be7a2cf6a8c14b1ea5011596bc6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 17:17:58 +0300 Subject: [PATCH 37/60] =?UTF-8?q?chore(ops):=20=D0=B2=D1=8B=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20+x=20=D0=BD=D0=B0=20docker-pru?= =?UTF-8?q?ne.sh=20=D0=B8=20restore.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба скрипта закоммичены как 100644, хотя соседние по каталогу backup.sh и uptime-healthcheck.sh — 100755. На прод-VM файлы лежат с +x, поэтому `git status` в /opt/gendesign постоянно показывает их как modified: M ops/docker-prune.sh M ops/restore.sh (old mode 100644 / new mode 100755, контент идентичен) Функционально это ничего не ломает: cron зовёт docker-prune.sh через `bash <путь>`, restore.sh запускают руками так же. Проблема в другом — постоянный M в прод-репозитории обесценивает единственный дешёвый сигнал, по которому видно ручную правку файла на проде. Шум надо убирать, а не привыкать к нему. Приводим режим к тому, что реально на диске и что уже стоит у соседей. --- ops/docker-prune.sh | 0 ops/restore.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 ops/docker-prune.sh mode change 100644 => 100755 ops/restore.sh diff --git a/ops/docker-prune.sh b/ops/docker-prune.sh old mode 100644 new mode 100755 diff --git a/ops/restore.sh b/ops/restore.sh old mode 100644 new mode 100755 From 9b3889bb36e1468f56a34b53290d2e277a8ff144 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 17:55:17 +0300 Subject: [PATCH 38/60] =?UTF-8?q?ci(deploy):=20=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D1=8F=20+=20=D0=BD=D0=B5=D1=84?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20buildcache?= =?UTF-8?q?=20(#2841)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зелёная галка прогона не отличима от пропущенного деплоя: если build падает из-за битого blob в удалённом buildcache, шаг deploy молча пропускается (if-условие даёт result=skipped), а прогон в целом не подсвечен как FAILED. - deploy-status: новая job в конце deploy.yml и deploy-tradein.yml, всегда бежит (if: always() && !cancelled()) и падает явно, если deploy.result != success — неважно, пропущен он (upstream build/test упал) или упал сам. - cache-from нефатален: каждый build-push-action-шаг получил id + continue- on-error, и ретрай без cache-from/cache-to при steps.build.outcome == 'failure'. Битый remote-кеш больше не роняет саму сборку; следующий успешный прогон с кешем перезаписывает buildcache-тег целиком (mode=max) и самолечит порчу. Реальные ошибки сборки (не кеш) по-прежнему валят job на ретрае — deploy-status их тоже поймает. Гейт против публикации services-портов на VPS (та же задача, проблема 1) уже покрыт scripts/check-workflow-ports.py + шагом в ci.yml (#2757/#2759, слит ранее) — сканирует все .forgejo/workflows/*.yml, включая эти два файла; новых правок не потребовалось. docker rm -f БЕЗ -v в SSH-скриптах деплоя не тронут — эти вызовы намеренно без -v (боевые тома), правка их не касается. --- .forgejo/workflows/deploy-tradein.yml | 91 +++++++++++++++++++++++++++ .forgejo/workflows/deploy.yml | 85 +++++++++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 50311309..8e1ba10a 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -264,6 +264,12 @@ jobs: id: buildx - name: Build & push tradein-backend + # id + continue-on-error: битый blob в удалённом buildcache-манифесте + # валит весь шаг ДО push нового образа — деплой тогда молча + # пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай + # без cache-from — ниже. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: # Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit @@ -284,6 +290,23 @@ jobs: ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Retry build & push tradein-backend без кеша (битый buildcache, #2841) + # cache-to тоже опущен: следующий успешный прогон С кешем перезапишет + # buildcache-тег целиком (mode=max) и самолечит порчу. + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp + file: ./tradein-mvp/backend/Dockerfile + push: true + build-args: | + APP_VERSION=${{ needs.changes.outputs.app_version }} + BUILD_SHA=${{ needs.changes.outputs.build_sha }} + BUILD_DATE=${{ needs.changes.outputs.build_date }} + tags: | + ${{ env.IMAGE_BACKEND }}:latest + ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -362,6 +385,10 @@ jobs: run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md - name: Build & push tradein-frontend + # id + continue-on-error — см. tradein-backend (#2841): битый blob в + # удалённом buildcache не должен ронять сборку и молча пропускать деплой. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./tradein-mvp/frontend @@ -386,6 +413,24 @@ jobs: ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841) + # См. tradein-backend: cache-to опущен намеренно (следующий успешный + # прогон с кешем перезапишет buildcache-тег целиком и самолечит порчу). + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp/frontend + push: true + build-args: | + NEXT_PUBLIC_BASE_PATH=/trade-in + NEXT_PUBLIC_API_BASE_URL=/trade-in + NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} + NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} + NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} + tags: | + ${{ env.IMAGE_FRONTEND }}:latest + ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -458,6 +503,10 @@ jobs: id: buildx - name: Build & push tradein-browser + # id + continue-on-error — см. tradein-backend выше (#2841): битый blob + # в удалённом buildcache не должен ронять сборку и молча пропускать деплой. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./tradein-mvp/browser @@ -468,6 +517,18 @@ jobs: ${{ env.IMAGE_BROWSER }}:latest ${{ env.IMAGE_BROWSER }}:${{ github.sha }} + - name: Retry build & push tradein-browser без кеша (битый buildcache, #2841) + # См. tradein-backend: cache-to опущен намеренно (следующий успешный + # прогон с кешем перезапишет buildcache-тег целиком и самолечит порчу). + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp/browser + push: true + tags: | + ${{ env.IMAGE_BROWSER }}:latest + ${{ env.IMAGE_BROWSER }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -1019,3 +1080,33 @@ jobs: # The changes job reads this file on the next run to compute cumulative diff. echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha echo "→ Deployed SHA marker updated: $GITHUB_SHA" + + # Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:` + # молча (result=skipped), когда `test` или один из build-* падает (например, + # битый blob в buildcache роняет `docker/build-push-action` — до ретрая + # выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это + # было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя + # tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`, + # кроме отмены прогона) и сама падает, если deploy не завершился success — + # неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/ + # health-check/сверка образов #2679). Красная точка встаёт именно там, где + # решение реально принято, а не там, где она случайно оказалась по цепочке if. + deploy-status: + runs-on: ubuntu-latest + needs: [test, build-backend, build-frontend, build-browser, deploy] + if: always() && !cancelled() + steps: + - name: Итог прогона — деплой обязан быть success, не skipped/failure + run: | + echo "test: ${{ needs.test.result }}" + echo "build-backend: ${{ needs.build-backend.result }}" + echo "build-frontend: ${{ needs.build-frontend.result }}" + echo "build-browser: ${{ needs.build-browser.result }}" + echo "deploy: ${{ needs.deploy.result }}" + if [ "${{ needs.deploy.result }}" != "success" ]; then + echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \ + "Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \ + "Смотри логи test/build-backend/build-frontend/build-browser/deploy выше." + exit 1 + fi + echo "✓ деплой прошёл успешно" diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 604e28ee..6c40d79d 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -113,6 +113,12 @@ jobs: id: buildx - name: Build & push backend (lean — без Chromium) + # id + continue-on-error: битый blob в удалённом buildcache-манифесте + # (registry cache, не local) валит весь шаг ДО push нового образа — + # деплой тогда молча пропускается (#2841), хотя код собрать можно, просто + # без кеша. cache-from нефатален: при падении ретраим БЕЗ него ниже. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./backend @@ -124,6 +130,21 @@ jobs: ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Retry build & push backend без кеша (битый buildcache, #2841) + # cache-to тоже опущен: следующий успешный прогон С кешем перезапишет + # buildcache-тег целиком (mode=max), это самолечит порчу. Если и retry + # упадёт — шаг красный БЕЗ continue-on-error, job честно FAILURE, и + # deploy ниже корректно пропускается (уже настоящая причина, не кеш). + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./backend + target: runner + push: true + tags: | + ${{ env.IMAGE_BACKEND }}:latest + ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -194,6 +215,10 @@ jobs: id: buildx - name: Build & push worker (с Chromium для Playwright) + # id + continue-on-error — см. build-backend выше (#2841): битый blob в + # удалённом buildcache не должен ронять сборку и молча пропускать деплой. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./backend @@ -205,6 +230,19 @@ jobs: ${{ env.IMAGE_WORKER }}:latest ${{ env.IMAGE_WORKER }}:${{ github.sha }} + - name: Retry build & push worker без кеша (битый buildcache, #2841) + # См. backend: cache-to опущен намеренно (следующий успешный прогон с + # кешем перезапишет buildcache-тег целиком и самолечит порчу). + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./backend + target: runner-with-chromium + push: true + tags: | + ${{ env.IMAGE_WORKER }}:latest + ${{ env.IMAGE_WORKER }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -275,6 +313,10 @@ jobs: id: buildx - name: Build & push frontend + # id + continue-on-error — см. build-backend выше (#2841): битый blob в + # удалённом buildcache не должен ронять сборку и молча пропускать деплой. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./frontend @@ -288,6 +330,21 @@ jobs: ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Retry build & push frontend без кеша (битый buildcache, #2841) + # См. backend: cache-to опущен намеренно (следующий успешный прогон с + # кешем перезапишет buildcache-тег целиком и самолечит порчу). + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./frontend + push: true + build-args: | + NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }} + NEXT_PUBLIC_ENVIRONMENT=production + tags: | + ${{ env.IMAGE_FRONTEND }}:latest + ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -627,3 +684,31 @@ jobs: curl -fsS http://localhost:8000/health && break sleep 1 done + + # Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:` + # молча (result=skipped), когда build падает (например, битый blob в + # buildcache роняет `docker/build-push-action` — до ретрая выше, #2841). + # skipped-job НЕ красит прогон явным «FAILED» так, чтобы это было видно на + # первый взгляд — итог выглядит зелёным/нейтральным, хотя прод не обновился. + # Эта job бежит ВСЕГДА (`if: always()`, кроме отмены прогона) и сама падает, + # если deploy не завершился success — неважно, пропущен он (build упал) или + # упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где + # решение реально принято, а не там, где она случайно оказалась по цепочке if. + deploy-status: + runs-on: ubuntu-latest + needs: [build-backend, build-worker, build-frontend, deploy] + if: always() && !cancelled() + steps: + - name: Итог прогона — деплой обязан быть success, не skipped/failure + run: | + echo "build-backend: ${{ needs.build-backend.result }}" + echo "build-worker: ${{ needs.build-worker.result }}" + echo "build-frontend: ${{ needs.build-frontend.result }}" + echo "deploy: ${{ needs.deploy.result }}" + if [ "${{ needs.deploy.result }}" != "success" ]; then + echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \ + "Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \ + "Смотри логи build-backend/build-worker/build-frontend/deploy выше." + exit 1 + fi + echo "✓ деплой прошёл успешно" From 3a1e29a7dabb3d155194090417c42c0af8d79cc6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 17:56:12 +0300 Subject: [PATCH 39/60] fix(tradein/deactivate): cap effective TTL floor at 2x configured value Revisit-floor (#2659) raises effective TTL via max(ttl_days, floor) with no upper bound -- a positive feedback loop confirmed on prod: slow crawl raises the floor, a high floor keeps stale listings marked active longer than a fresh sweep needs to return, the "active" pool bloats with rot, and the next floor measurement on that bloated pool comes out even higher. Yandex counters sat at ttl_days_effective=75/75/75/39/52/54 for six runs straight with deactivated=0; 23,687/44,744 "active" avito listings hadn't been confirmed in >7 days, cian 10,572/19,514 and yandex 7,178/15,790 were >30 days stale, the oldest "active" row hadn't been seen in 86 days. CAP_MULT=2 caps the floor's upward push without disabling it -- the floor still protects against premature deactivation during genuinely slow (but alive) crawl cycles, it just can no longer grow unbounded. Beyond 2x, a persistently low crawl rate is better handled by the existing health gate (min_confirmations), which disables deactivation outright instead of stretching TTL forever. When the cap binds, counters gain ttl_floor_capped=1 + ttl_days_floor_raw (the uncapped value) so it's visible in the run-history dashboard, not just logs -- counters are stored as-is in scrape_runs.counters. Single fix point: all four sources (avito/yandex/cian/domklik) route through this one deactivate_stale_listings() via the product_handlers wildcard "deactivate_stale_*" handler, so no other task file needed the change. --- .../app/tasks/deactivate_stale_avito.py | 68 +++++- .../test_deactivate_stale_revisit_floor.py | 14 +- .../tests/test_deactivate_stale_ttl_cap.py | 198 ++++++++++++++++++ 3 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index cdab053b..52f82498 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -190,6 +190,32 @@ DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 _REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))" +# ── Потолок эффективного TTL (положительная обратная связь пола, найдено 2026-08-15) ── +# У пола выше нет верхней границы: max(ttl_days, пол) может расти неограниченно. +# ЗАМЕР НА ПРОДЕ, из-за которого этот потолок существует: 23 687 из 44 744 «активных» +# avito-объявлений не подтверждались >7 суток; cian 10 572/19 514 и yandex 7 178/15 790 +# — старше 30 суток; самая старая «активная» запись не видена 86 суток. В пуле +# сравнимых 3 497 просроченных строк. У yandex counters держали ttl_days_effective +# 75/75/75/39/52/54 шесть прогонов подряд при deactivated=0. +# +# МЕХАНИЗМ ПЕТЛИ: медленный обход поднимает пол (он же квантиль разрывов переобхода) +# -> высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться +# свежий обход -> пул «активных» раздувается «протухшими» строками -> следующий замер +# пола на том же раздутом пуле оказывается ещё выше. Без верхней границы это не +# самокорректирующийся пол, а положительная обратная связь. +# +# CAP_MULT = 2 -- эффективный TTL не может превысить удвоенный заданный оператором +# ttl_days. Пол по-прежнему может его поднять (ради #2659 -- см. комментарий выше: +# ложные снятия при неполном покрытии обхода), но не бесконечно. Почему именно 2, а +# не 3 или 1.5: вдвое — это ещё «мы искренне не уверены, что молчание значит +# снятие», не «источник вообще умер». Дальнейший рост пола сигнализирует не о +# медленном, но живом обходе, а о мёртвом источнике -- для ЭТОГО случая уже есть +# отдельный гейт по здоровью (min_confirmations) выше в этой же функции, который +# выключает деактивацию целиком, а не растягивает TTL до бесконечности. Калибровочная +# ручка, не догма -- при новом замере можно пересмотреть, как и revisit_floor_quantile. +CAP_MULT = 2 + + def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any: """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива. @@ -334,7 +360,8 @@ def deactivate_stale_listings( health_window_days: окно подтверждений для гейта, суток. Дефолт 3. revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659). Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой; - эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так + эффективный TTL = min(max(ttl_days, этот пол), ttl_days * CAP_MULT) -- + пол поднимает TTL, но не выше потолка. 0 -> пол выключен (так вызывают старые тесты и совместимая обёртка), рабочее значение — DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше. @@ -345,7 +372,9 @@ def deactivate_stale_listings( Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками). Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N, "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял - TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. + TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Если пол + упёрся в потолок CAP_MULT: дополнительно {"ttl_floor_capped": 1, + "ttl_days_floor_raw": N} -- N это то, во что пол поднял бы TTL БЕЗ потолка. Raises: ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, @@ -402,7 +431,8 @@ def deactivate_stale_listings( return counters # Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же - # срезу. Поднимает порог, никогда не опускает: max(), а не замена. + # срезу. Поднимает порог (max), но не выше потолка CAP_MULT * ttl_days (min) — + # см. комментарий у CAP_MULT про петлю с положительной обратной связью. effective_ttl_days = ttl_days if revisit_floor_quantile > 0: floor_params: dict[str, Any] = { @@ -420,9 +450,37 @@ def deactivate_stale_listings( # Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего. if floor_days is not None: counters["revisit_floor_days"] = ceil(float(floor_days)) - effective_ttl_days = max(ttl_days, counters["revisit_floor_days"]) + # Пол поднимает TTL (max), потолок CAP_MULT его не пускает выше + # ttl_days * CAP_MULT (min) — без этого пол растёт без ограничения + # (см. комментарий у CAP_MULT). + raw_effective_ttl_days = max(ttl_days, counters["revisit_floor_days"]) + capped_ttl_days = ttl_days * CAP_MULT + effective_ttl_days = min(raw_effective_ttl_days, capped_ttl_days) counters["ttl_days_effective"] = effective_ttl_days - if effective_ttl_days > ttl_days: + + if raw_effective_ttl_days > capped_ttl_days: + # Пол упёрся в потолок -- оба числа в counters (не только в логе), + # чтобы это было видно в витрине прогонов, а не только в логах. + # 1, а не True -- counters типизирован dict[str, int] (тот же + # идиом, что skipped_unhealthy выше). + counters["ttl_floor_capped"] = 1 + counters["ttl_days_floor_raw"] = raw_effective_ttl_days + logger.warning( + "deactivate_stale source=%s run_id=%d TTL пол упёрся в потолок " + "CAP_MULT=%d: пол поднял бы TTL до %d сут, потолок ограничивает " + "заданные %d сут значением %d (квантиль %.3f, segments=%r) — " + "растущий без ограничения пол это петля с положительной обратной " + "связью, см. комментарий у CAP_MULT", + listing_source, + run_id, + CAP_MULT, + raw_effective_ttl_days, + ttl_days, + effective_ttl_days, + revisit_floor_quantile, + segments, + ) + elif effective_ttl_days > ttl_days: logger.warning( "deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: " "свип за %d сут доказал живой строку, молчавшую %d сут " diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py index 76f34665..da98c69d 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -126,13 +126,21 @@ def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[st def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None: - """Ни одно из 127 доказанно ложных снятий не должно повториться. + """Ни одно из 127 доказанно ложных снятий (cian/yandex) не должно повториться. Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть - строго выше этого возраста на КАЖДОМ прод-срезе — иначе следующий прогон - снимет ту же строку снова. + строго выше этого возраста на cian/yandex-срезах — иначе следующий прогон + снимет ту же строку снова. avito пропущен намеренно: 127 доказанных ложных + снятий (_FALSE_KILLS_BY_CITY) измерены только по cian/yandex, а гипотетический + замер пола avito=69.7 при ttl=10 -- ровно тот случай, для которого заведён + потолок CAP_MULT (#TTL-CAP, 2026-08-15): без потолка пол растёт без + ограничения (петля с положительной обратной связью, найдена на проде), + покрытие такого выброса потолком намеренно НЕ гарантируется -- см. + test_deactivate_stale_ttl_cap.py. """ for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items(): + if source == "avito": + continue db = _FakeDB(floor_days=floor) out = _run( db, diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py new file mode 100644 index 00000000..36f28a07 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py @@ -0,0 +1,198 @@ +"""Потолок эффективного TTL деактивации (найдено на проде 2026-08-15). + +Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) поднимает +эффективный TTL через max(ttl_days, пол) без верхней границы. На проде это оказалось +петлёй с положительной обратной связью: медленный обход поднимает пол, высокий пол +продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться свежий обход, пул +«активных» раздувается протухшими строками -- 23 687 из 44 744 avito-строк не +подтверждались >7 суток; cian 10 572/19 514 и yandex 7 178/15 790 -- старше 30 суток; +самая старая «активная» запись не видена 86 суток. У yandex ttl_days_effective держали +75/75/75/39/52/54 шесть прогонов подряд при deactivated=0. + +Этот файл проверяет CAP_MULT -- потолок, не пускающий эффективный TTL выше +ttl_days * CAP_MULT, независимо от того, насколько высоко посчитанный пол. +""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import deactivate_stale_avito as task_mod + +# ── Фейковая сессия (тот же контракт, что в test_deactivate_stale_revisit_floor.py) ── + + +class _FakeResult: + def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None: + self.rowcount = rowcount + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + +class _FakeDB: + """Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount.""" + + def __init__( + self, + *, + floor_days: float | None, + confirmations: int = 10_000, + rowcount: int = 137, + ) -> None: + self._floor = floor_days + self._confirmations = confirmations + self._rowcount = rowcount + self.executed: list[tuple[str, dict[str, Any] | None]] = [] + self.committed = False + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt.text) + self.executed.append((sql, params)) + if "percentile_disc" in sql: + return _FakeResult(scalar_value=self._floor) + if "SELECT count(*)" in sql: + return _FakeResult(scalar_value=self._confirmations) + return _FakeResult(rowcount=self._rowcount) + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: + self.rolled_back = True + + @property + def update_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None)) + + +def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]: + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + return task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 1, + listing_source=kwargs.pop("listing_source", "avito"), + ttl_days=kwargs.pop("ttl_days", 10), + **kwargs, + ) + + +# ── Контракт из задачи ───────────────────────────────────────────────────────── + + +def test_high_floor_is_capped_at_double_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """revisit_floor=75, ttl_days=10 -> итог 20 (потолок 2x), НЕ 75.""" + db = _FakeDB(floor_days=75.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 75 + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 20, "UPDATE обязан получить капнутый TTL, не сырой пол" + + +def test_low_floor_leaves_ttl_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + """revisit_floor=5, ttl_days=10 -> итог 10 (пол ниже заданного TTL, max() его не поднимает).""" + db = _FakeDB(floor_days=5.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + assert "ttl_floor_capped" not in out + assert "ttl_days_floor_raw" not in out + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 10 + + +# ── Контракт потолка ──────────────────────────────────────────────────────────── + + +def test_cap_mult_is_named_module_constant_equal_two() -> None: + assert task_mod.CAP_MULT == 2 + + +def test_floor_between_ttl_and_cap_is_not_flagged_capped(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол поднял TTL, но не дотянулся до потолка -- capped-флаг НЕ выставляется.""" + db = _FakeDB(floor_days=15.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 15 + assert "ttl_floor_capped" not in out + + +def test_floor_exactly_at_cap_boundary_is_not_flagged_capped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пол ровно на потолке (2x ttl) -- это ещё "поднят до потолка", не "срезан выше него". + + Формула -- min(raw, cap): при raw == cap срезания не происходит (raw > cap ложно), + капнутый флаг предназначен сигналить именно "потолок реально что-то отрезал". + """ + db = _FakeDB(floor_days=20.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert "ttl_floor_capped" not in out + + +def test_cap_logs_warning_containing_both_numbers( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """WARNING при срезании содержит и сырой пол, и капнутый результат -- не только counters.""" + db = _FakeDB(floor_days=75.0) + with caplog.at_level("WARNING", logger=task_mod.logger.name): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + messages = " ".join(r.getMessage() for r in caplog.records) + assert "75" in messages, "лог обязан называть сырой пол" + assert "20" in messages, "лог обязан называть итоговый (капнутый) TTL" + + +def test_cap_never_lowers_ttl_below_configured_value(monkeypatch: pytest.MonkeyPatch) -> None: + """Потолок -- верхняя граница, не альтернативный источник истины: заданный TTL + (10) остаётся нижней границей независимо от того, насколько низко ушёл пол.""" + db = _FakeDB(floor_days=1.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + + +# ── avito self-descend (52 -> ... -> 10) не должен ломаться потолком ──────────── + + +def test_avito_high_transient_floor_is_capped_not_left_unbounded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Наблюдённый на проде транзиентный пик avito (счётчики видели ttl_days_effective=52) + теперь капается на 2x ttl=20, а не пропускается в UPDATE как есть.""" + db = _FakeDB(floor_days=52.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 52 + + +def test_avito_recovered_low_floor_still_reaches_configured_ttl( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """После восстановления обхода (пол опустился ниже ttl_days=10, как на проде 52->10) + потолок не мешает нормальному пути -- эффективный TTL просто равен заданному.""" + db = _FakeDB(floor_days=9.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + assert "ttl_floor_capped" not in out + + +def test_avito_floor_above_ttl_but_under_cap_passes_through_uncapped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Промежуточная точка того же самопонижения (пол между ttl и потолком, например 18) + поднимает TTL как раньше -- потолок не мешает нормальному постепенному пути.""" + db = _FakeDB(floor_days=18.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 18 + assert "ttl_floor_capped" not in out From 24b70e5c58679d78ab092b2df9217ca4f51ba805 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 17:58:36 +0300 Subject: [PATCH 40/60] =?UTF-8?q?fix(tradein):=20HEAD=20/health=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=87=D0=B0=D0=B5=D1=82=20200=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20405?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @app.get("/health") в FastAPI/Starlette не добавляет HEAD-обработчик автоматически (в отличие от низкоуровневого Route(methods=["GET"])) — внешний uptime-monитор (GlitchTip PING-тип шлёт HEAD) получал 405 и не мог отличить "жив" от "мёртв" по статусу. Добавлен явный @app.head("/health") — 200 без тела (RFC 9110 §9.3.2), GET не тронут. Тест test_health_endpoint.py фиксирует оба метода; RED до фикса (HEAD → 405), GREEN после (проверено git stash + повторный прогон). --- tradein-mvp/backend/app/main.py | 13 +++++++- .../backend/tests/test_health_endpoint.py | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_health_endpoint.py diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 347cad8c..4f21a09a 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -12,7 +12,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import sentry_sdk -from fastapi import FastAPI +from fastapi import FastAPI, Response from fastapi.middleware.cors import CORSMiddleware from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.httpx import HttpxIntegration @@ -210,6 +210,17 @@ def health() -> dict[str, str]: return {"status": "ok", "environment": settings.environment} +# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от +# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health +# отдаёт 405, и внешний uptime-monitor (GlitchTip PING-тип, HEAD-запрос) не +# может отличить "жив" от "мёртв" по статусу. Тело для HEAD не отдаём — так +# требует HTTP-спека (RFC 9110 §9.3.2): у ответа те же заголовки, что у GET, +# но без body. +@app.head("/health") +def health_head() -> Response: + return Response(status_code=200) + + 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"]) diff --git a/tradein-mvp/backend/tests/test_health_endpoint.py b/tradein-mvp/backend/tests/test_health_endpoint.py new file mode 100644 index 00000000..1fa05757 --- /dev/null +++ b/tradein-mvp/backend/tests/test_health_endpoint.py @@ -0,0 +1,31 @@ +"""GET/HEAD /health — uptime-monitor honesty (#uptime-honest-green). + +GlitchTip PING-мониторы шлют HEAD (или GET без чтения тела). Голый +`@app.get("/health")` без явного HEAD-хендлера отдаёт 405 на HEAD — Starlette +НЕ добавляет HEAD автоматически к FastAPI `@app.get()` роуту (в отличие от +низкоуровневого `Route(methods=["GET"])`). Прод-симптом: `HEAD /health` → 405, +монитор либо красный по конструкции, либо (при PING без сверки статуса) +зелёный вне зависимости от факта. Тест фиксирует оба метода. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + + +def test_health_get_ok() -> None: + client = TestClient(app) + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + + +def test_health_head_ok_no_body() -> None: + """HEAD /health — то, что реально шлёт uptime-monitor. Должен быть 200, без тела.""" + client = TestClient(app) + resp = client.head("/health") + assert resp.status_code == 200 + assert resp.content == b"" From 885031420eca90a14500bc93a7d22d5aa63534c1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:06:33 +0300 Subject: [PATCH 41/60] =?UTF-8?q?fix(tradein/scrapers):=20honest=20run=20s?= =?UTF-8?q?tatus=20=E2=80=94=20=D1=81=D1=82=D0=BE=D0=BF=20'done'=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=B8=20=D0=BD=D1=83=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три прод-факта, где status='done' врал о реальном исходе прогона: - avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1} -> 'done'. mark_backfill_finished звал mark_done, потому что produced=6 (>0); ни _sweep_run_did_nothing (нет anchors_total/errors_count у backfill'ов), ни _phase_totally_failed (голые "attempted"/"failed" без фазового префикса) эту форму counters не ловили. Новый _failed_ratio_too_high внутри mark_done: failed/attempted >= 0.5 -> 'failed', >= 0.15 -> тоже 'failed' (другая формулировка причины в error-тексте) — 'partial' статусом не заведён: это потребовало бы DROP+ADD CHECK constraint (051_scrape_runs_extend.sql) и дообучения ещё 4 мест (Literal-фильтр admin API, статусы фронта, оба IN-списка сторожей) — тот же класс проводки, что и у ban_kind (#2686/#2764), который сознательно не стал новым статусом. - yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при processed=5 succeeded=0 rows_inserted=0 failed_resolve=4-5 — сторож нулевого результата (_alert_if_consecutive_zero_results) не видел ни один результатный ключ этого sweep'а и молчал навсегда. _RESULT_COUNTER_KEYS дополнен rows_inserted/processed (именно в этом порядке — rows_inserted это результат, processed это попытки; иначе "5 обработано, 0 записано" замаскировалось бы под measured-5). - admin-витрина показывала new_count=0 у трёх подряд cian_full_load при реально сохранённых saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count', ни 'lots_inserted'. _column_counts дополнен saved_inserted/rows_inserted. Правки продублированы в scraper_kit/orchestration/runs.py (byte-эквивалент app.services.scrape_runs, см. докстринг модуля) для параллели: единственный текущий писатель "attempted"/"failed" (mark_backfill_finished) живёт только в app-копии, но приоритет ключей/константы держим синхронными на будущее. Не тронуто: сознательно пустые sweep'ы (errors_count=0, honest empty) и малые батчи (attempted < 3) — доля отказов на них не считается диагнозом. Tests: tests/test_honest_run_status_failed_ratio.py (41 кейс, оба модуля, включая точные прод-числа из трёх фактов выше) + regression-прогон 609 тестов по всем файлам, трогающим scrape_runs/orchestration.runs — 0 регрессий. --- .../backend/app/services/scrape_runs.py | 102 ++++++- .../test_honest_run_status_failed_ratio.py | 267 ++++++++++++++++++ .../src/scraper_kit/orchestration/runs.py | 107 ++++++- 3 files changed, 468 insertions(+), 8 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 0ecf5ee2..ccac68af 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -143,6 +143,18 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше # сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов # реально дали ноль. +# rows_inserted — yandex_newbuilding_sweep (единственный писатель ключа с таким +# именем на верхнем уровне counters): проверено на проде 26.07-10.08 — +# десять прогонов подряд, все 'done', processed=5 succeeded=0 +# rows_inserted=0 failed_resolve=4-5. Ни total_seen/lots_fetched/ +# unique_fetched у него нет, поэтому раньше _run_result_count всегда +# возвращал None ("не измерено") и стрик у сторожа не копился никогда +# (honest-run-status). +# processed — тот же sweep: сколько домов взял в работу. НАМЕРЕННО стоит ПОСЛЕ +# rows_inserted в кортеже — processed это счётчик ПОПЫТОК (аналог +# attempted), а не результата: у него ненулевое значение (=limit) даже +# когда rows_inserted=0, и если бы он читался первым, «5 обработано, +# 0 записано» замаскировалось бы под measured-5, а не measured-0. # Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 # прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь # (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched @@ -150,7 +162,13 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ # (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их # словарь, а честно признаёт, что мерить нечем — см. _run_result_count. -_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched") +_RESULT_COUNTER_KEYS = ( + "total_seen", + "lots_fetched", + "unique_fetched", + "rows_inserted", + "processed", +) def _run_result_count(counters: Mapping[str, Any] | None) -> int | None: @@ -282,6 +300,63 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: return None +# honest-run-status (2026-08-15): доля отказов, которая обесценивает формально ненулевой +# сбор. Прод-факт avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6, +# "blocked":1} — 89% попыток отказали, а mark_backfill_finished всё равно звал mark_done, +# потому что "produced != 0" (6 обогащено). Ни _sweep_run_did_nothing (нужны +# anchors_total/errors_count, у backfill'ов их нет), ни _phase_totally_failed (нужна пара +# "_attempted"/"_failed" — здесь голые "attempted"/"failed" без фазового +# префикса, `"attempted".endswith("_attempted")` не матчит) эту форму counters не ловят — +# обе проверки написаны под СВОИ формы, а не под backfill'овскую. +# +# Порог 'failed' — половина и больше отказов: сбор для практических целей провалился, +# даже если несколько записей всё же обогатились. Порог 'partial' НЕ заведён отдельным +# статусом scrape_runs.status — это потребовало бы миграции (DROP+ADD CHECK constraint, +# 051_scrape_runs_extend.sql) и обучило бы новому значению ещё 4 места (Literal-фильтр +# admin API, хардкод статусов фронта, оба IN-списка сторожей) — тот же класс "оборванной +# проводки", из-за которого заведён #2686/ban_kind. Вместо статуса — тот же диагноз, что и +# у ban_kind: causa в тексте `error`, терминальный статус один ('failed'). 0.15..0.5 — +# та же 'failed', но с другой формулировкой причины ("деградировал", не "провалился"), чтобы +# оператор видел разницу читая error, не только status. +FAILED_RATIO_FAILED_THRESHOLD = 0.5 +FAILED_RATIO_DEGRADED_THRESHOLD = 0.15 +# Минимум попыток, при котором доля вообще что-то значит — иначе 1 отказ из 2 (=0.5) +# палит статус на шуме единичного случая. То же рассуждение и то же число, что у +# _PHASE_MIN_ATTEMPTS (см. выше). +_FAILED_RATIO_MIN_ATTEMPTS = _PHASE_MIN_ATTEMPTS + + +def _failed_ratio_too_high(counters: Mapping[str, Any]) -> str | None: + """Прогон, у которого доля отказов слишком велика, даже если что-то собрано. + + Возвращает текст причины (для error) либо None. Читает ГОЛЫЕ ключи "attempted"/ + "failed" (без фазового префикса) — сейчас это словарь только у четырёх + detail-backfill'ов (avito/yandex/domclick/newbuilding_enrich), все идут через + mark_backfill_finished → mark_done. `attempted < _FAILED_RATIO_MIN_ATTEMPTS` или + отсутствие любого из ключей → None (нечем/не о чём судить — счётчики либо не + заполнены, либо принадлежат другому источнику со своим словарём). + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и у #2625/#2700/#2764. + """ + attempted = _pick_int(counters, "attempted") + failed = _pick_int(counters, "failed") + if attempted is None or failed is None or attempted < _FAILED_RATIO_MIN_ATTEMPTS: + return None + ratio = failed / max(attempted, 1) + if ratio >= FAILED_RATIO_FAILED_THRESHOLD: + verb = "провалился" + elif ratio >= FAILED_RATIO_DEGRADED_THRESHOLD: + verb = "деградировал" + else: + return None + return ( + f"failed-ratio-honest-status: сбор {verb} — {failed} из {attempted} попыток " + f"отказали (доля {ratio:.0%}); формально ненулевой результат этого не искупает. " + f"Причина НЕ установлена — статус 'failed' без диагноза" + ) + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -292,13 +367,22 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926). Приоритет ключей: - - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched) - - new_count ← 'new_count' (если уже есть) иначе 'lots_inserted' + - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched / + rows_inserted / processed) + - new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted' + (первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex, + CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала + new_count=0 у трёх подряд cian_full_load при реально сохранённых + saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted' + у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ, + которым yandex_newbuilding_sweep сообщает число upsert'ов. Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). """ - return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted") + return _run_result_count(counters), _pick_int( + counters, "new_count", "lots_inserted", "saved_inserted", "rows_inserted" + ) def _alert_if_consecutive_failures(db: Session, source: str) -> None: @@ -558,6 +642,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет». + + honest-run-status: там же — отказ называть успехом прогон с высокой долей отказов, + даже если собрано > 0 (см. _failed_ratio_too_high). Отличие от #2625/#2700: те два + смотрят на «всё или ничего» (все якоря / вся фаза), этот — на ДОЛЮ отказов у + detail-backfill'ов, где ни один из первых двух признаков не матчит форму counters. """ did_nothing = _sweep_run_did_nothing(counters) if did_nothing is not None: @@ -569,6 +658,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: logger.error("%s run_id=%d", phase_dead, run_id) mark_failed(db, run_id, phase_dead, counters) return + ratio_bad = _failed_ratio_too_high(counters) + if ratio_bad is not None: + logger.error("%s run_id=%d", ratio_bad, run_id) + mark_failed(db, run_id, ratio_bad, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( diff --git a/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py new file mode 100644 index 00000000..b8c03639 --- /dev/null +++ b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py @@ -0,0 +1,267 @@ +"""honest-run-status (2026-08-15): статус прогона не должен рапортовать 'done' поверх +провала или нуля. Три прод-факта закрыты этой правкой: + + (a) avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1} + -> status='done' — 89% отказов, статус зелёный. mark_backfill_finished звал + mark_done, потому что produced=6 (>0); ни _sweep_run_did_nothing (нет + anchors_total/errors_count у backfill'ов), ни _phase_totally_failed (ключи + "attempted"/"failed" без фазового префикса) эту форму counters не ловили. + Фикс: _failed_ratio_too_high внутри mark_done. + + (b) yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при + processed=5, succeeded=0, rows_inserted=0, failed_resolve=4-5 — сторож нулевого + результата (_alert_if_consecutive_zero_results) слеп, т.к. _RESULT_COUNTER_KEYS + не знал ни одного ключа этого sweep'а (total_seen/lots_fetched/unique_fetched). + Фикс: _RESULT_COUNTER_KEYS дополнен rows_inserted/processed (в этом порядке — + rows_inserted это РЕЗУЛЬТАТ, processed это ПОПЫТКИ). + + (c) admin-витрина показывала new_count=0 у трёх подряд cian_full_load, хотя реально + сохранено saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count', ни + 'lots_inserted'. Фикс: _column_counts дополнен saved_inserted/rows_inserted. + +Проверяем на обоих модулях (kit-копия и app-копия — байт-эквивалентны по докстрингу +runs.py), тем же паттерном, что test_2625_run_that_did_nothing.py. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} + + +def _capture_status(mod: Any, counters: dict[str, Any]) -> list[str]: + """Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов. + + Тот же helper, что в test_2625_run_that_did_nothing.py — читаем СТАТУС В SQL, а не + имя вызванной функции. + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(mod, "sentry_sdk", MagicMock()): + mod.mark_done(db, 1, dict(counters)) + return statuses + + +def _capture_backfill_status( + counters: dict[str, Any], *, source: str = "avito_detail_backfill", aborted: bool = False +) -> list[str]: + """Прогнать app_runs.mark_backfill_finished на фейковой сессии (mark_done НЕ мокан — + в отличие от test_backfill_honest_status.py, здесь важно именно его РЕАЛЬНОЕ + поведение: mark_backfill_finished решает вызвать mark_done, а решает ли mark_done + остаться 'done' или сам себя переквалифицировать в 'failed' — предмет этого теста). + + mark_backfill_finished есть только в app_runs (kit-копия его не держит — см. + docstring модуля runs.py, "mark_skipped есть только здесь" — тот же принцип + относится к продуктовым финализаторам detail-backfill'ов). + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(app_runs, "sentry_sdk", MagicMock()): + app_runs.mark_backfill_finished( + db, 1, dict(counters), source=source, aborted_by_blocks=aborted + ) + return statuses + + +# ── (a) failed_ratio: прод-факт avito_detail_backfill 15.08 ───────────────────────── + + +def test_prod_fact_avito_15_08_no_longer_done() -> None: + """{"attempted":64,"failed":57,"enriched":6,"blocked":1} — 89% отказов — 'failed', + НЕ 'done'. Красный на старом коде (produced=6 != 0 -> mark_done -> 'done').""" + counters = {"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1} + assert _capture_backfill_status(counters) == ["failed"] + + +def test_prod_fact_avito_reason_names_the_ratio() -> None: + reason = app_runs._failed_ratio_too_high( + {"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1} + ) + assert reason is not None + assert "failed-ratio-honest-status" in reason + assert "57 из 64" in reason + assert "89%" in reason + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize( + ("counters", "flagged", "why"), + [ + ({"attempted": 64, "failed": 57}, True, "прод-факт: 89% отказов"), + ({"attempted": 10, "failed": 5}, True, "ровно порог failed (0.5)"), + ({"attempted": 20, "failed": 3}, True, "ровно порог degraded (0.15)"), + ({"attempted": 20, "failed": 2}, False, "ниже порога degraded (0.10)"), + ({"attempted": 2, "failed": 2}, False, "ratio=1.0, но < _FAILED_RATIO_MIN_ATTEMPTS"), + ({"attempted": 0, "failed": 0}, False, "нет попыток вовсе"), + ({"failed": 5}, False, "нет attempted — чужой словарь"), + ({"attempted": 50}, False, "нет failed — чужой словарь"), + ({}, False, "пустые counters"), + ( + {"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, + False, + "sweep-словарь (anchors_total), не detail-backfill", + ), + ], +) +def test_failed_ratio_classifier_boundaries( + name: str, counters: dict[str, Any], flagged: bool, why: str +) -> None: + reason = _MODULES[name]._failed_ratio_too_high(counters) + assert (reason is not None) is flagged, why + + +# ── (5) не должен палить прогоны с малой/умеренной долей отказов ──────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_low_failure_ratio_stays_done(name: str) -> None: + """Штатный шум (10% отказов) не становится 'failed' — не каждый отказ диагноз.""" + counters = {"attempted": 50, "enriched": 45, "failed": 5} + assert _capture_status(_MODULES[name], counters) == ["done"] + + +def test_tiny_batch_zero_produced_fails_via_old_rule_not_ratio() -> None: + """2 попытки, обе отказали, produced=0 — доля тут не при чём (attempted < floor + _FAILED_RATIO_MIN_ATTEMPTS, _failed_ratio_too_high вернул бы None); статус всё + равно 'failed', но по СТАРОМУ правилу #2674 (produced==0), внутри + mark_backfill_finished — mark_done/_failed_ratio_too_high тут не вызываются вовсе. + Показывает, что новая проверка не дублирует и не подменяет старую.""" + counters = {"attempted": 2, "enriched": 0, "failed": 2} + assert _capture_backfill_status(counters) == ["failed"] + + +def test_tiny_batch_with_partial_success_stays_done() -> None: + """2 попытки, 1 успех, 1 отказ (ratio=0.5, но attempted < floor=3) — стрик слишком + короткий, чтобы доля что-то значила -> остаётся 'done'.""" + counters = {"attempted": 2, "enriched": 1, "failed": 1} + assert _capture_backfill_status(counters) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_honest_empty_sweep_unaffected_by_failed_ratio(name: str) -> None: + """Сознательно спящее расписание (город без новостроек): sweep-словарь без + attempted/failed вовсе -> failed_ratio не о чем судить, честная пустота остаётся + 'done' (см. также test_2625_run_that_did_nothing.py::test_honest_empty_stays_done).""" + counters = {"anchors_total": 1, "errors_count": 0, "lots_fetched": 0} + assert _capture_status(_MODULES[name], counters) == ["done"] + + +# ── (b) _RESULT_COUNTER_KEYS: прод-факт yandex_newbuilding_sweep 26.07-10.08 ───────── + + +def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None: + """processed=5, succeeded=0, rows_inserted=0, failed_resolve=4 — раньше + _run_result_count возвращал None ("не измерено"); теперь — измеренный 0.""" + counters = { + "total": 309, + "fetchable": 200, + "pending": 50, + "processed": 5, + "skipped_already_enriched": 0, + "succeeded": 0, + "resolved_slug": 1, + "failed_resolve": 4, + "failed_fetch": 0, + "rows_inserted": 0, + "duration_sec": 42.0, + } + assert app_runs._run_result_count(counters) == 0 + assert kit_runs._run_result_count(counters) == 0 + + +def test_rows_inserted_takes_priority_over_processed() -> None: + """rows_inserted (результат) читается ПЕРЕД processed (попытки) — иначе "5 + обработано, 0 записано" замаскировалось бы под measured-5.""" + counters = {"processed": 5, "rows_inserted": 0} + assert app_runs._run_result_count(counters) == 0 + + +def test_processed_is_fallback_when_rows_inserted_absent() -> None: + counters = {"processed": 3} + assert app_runs._run_result_count(counters) == 3 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) -> None: + """(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с + rows_inserted=0 -> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал + результат "не измеренным" и молчал бы вечно (см. #2703 в docstring модуля).""" + mod = _MODULES[name] + row = MagicMock() + row.status = "done" + row.counters = {"processed": 5, "succeeded": 0, "rows_inserted": 0, "failed_resolve": 4} + db = MagicMock() + result = MagicMock() + result.fetchall.return_value = [row, row, row] + db.execute.return_value = result + with patch.object(mod, "sentry_sdk") as mock_sentry: + mod._alert_if_consecutive_zero_results(db, "yandex_newbuilding_sweep") + mock_sentry.capture_message.assert_called_once() + + +# ── (c) _column_counts: прод-факт cian_full_load new_count=0 при saved_inserted>0 ─── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_prod_fact_cian_full_load_saved_inserted_surfaces_as_new_count(name: str) -> None: + """saved_inserted=482 (прод-факт: три подряд прогона 482/214/239) — new_count + больше не 0, хотя ключей 'new_count'/'lots_inserted' в counters нет вовсе.""" + counters = {"unique_fetched": 1200, "saved_inserted": 482, "saved_updated": 30} + total_seen, new_count = _MODULES[name]._column_counts(counters) + assert total_seen == 1200 + assert new_count == 482 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_yandex_newbuilding_rows_inserted_surfaces_as_new_count(name: str) -> None: + counters = {"rows_inserted": 7} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 7 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_new_count_priority_unchanged_by_new_keys(name: str) -> None: + """'new_count' явный ключ всё ещё побеждает 'lots_inserted'/'saved_inserted' — + расширение списка не меняет приоритет уже существующих ключей.""" + counters = {"new_count": 5, "lots_inserted": 99, "saved_inserted": 1} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 5 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_lots_inserted_still_beats_saved_inserted(name: str) -> None: + """Порядок пикулярно НЕ переставлен для уже существующей пары — 'lots_inserted' + (city/newbuilding-sweep'ы) проверяется раньше 'saved_inserted' (full-load'ы), + т.к. это разные, непересекающиеся семейства источников.""" + counters = {"lots_inserted": 12, "saved_inserted": 999} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 12 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 86729701..279ea928 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -138,6 +138,18 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше # сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов # реально дали ноль. +# rows_inserted — yandex_newbuilding_sweep (единственный писатель ключа с таким +# именем на верхнем уровне counters): проверено на проде 26.07-10.08 — +# десять прогонов подряд, все 'done', processed=5 succeeded=0 +# rows_inserted=0 failed_resolve=4-5. Ни total_seen/lots_fetched/ +# unique_fetched у него нет, поэтому раньше _run_result_count всегда +# возвращал None ("не измерено") и стрик у сторожа не копился никогда +# (honest-run-status). +# processed — тот же sweep: сколько домов взял в работу. НАМЕРЕННО стоит ПОСЛЕ +# rows_inserted в кортеже — processed это счётчик ПОПЫТОК (аналог +# attempted), а не результата: у него ненулевое значение (=limit) даже +# когда rows_inserted=0, и если бы он читался первым, «5 обработано, +# 0 записано» замаскировалось бы под measured-5, а не measured-0. # Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 # прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь # (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched @@ -145,7 +157,13 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ # (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их # словарь, а честно признаёт, что мерить нечем — см. _run_result_count. -_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched") +_RESULT_COUNTER_KEYS = ( + "total_seen", + "lots_fetched", + "unique_fetched", + "rows_inserted", + "processed", +) def _run_result_count(counters: Mapping[str, Any] | None) -> int | None: @@ -277,6 +295,68 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: return None +# honest-run-status (2026-08-15): доля отказов, которая обесценивает формально ненулевой +# сбор. Прод-факт avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6, +# "blocked":1} — 89% попыток отказали, а mark_backfill_finished всё равно звал mark_done, +# потому что "produced != 0" (6 обогащено). Ни _sweep_run_did_nothing (нужны +# anchors_total/errors_count, у backfill'ов их нет), ни _phase_totally_failed (нужна пара +# "_attempted"/"_failed" — здесь голые "attempted"/"failed" без фазового +# префикса, `"attempted".endswith("_attempted")` не матчит) эту форму counters не ловят — +# обе проверки написаны под СВОИ формы, а не под backfill'овскую. +# +# Порог 'failed' — половина и больше отказов: сбор для практических целей провалился, +# даже если несколько записей всё же обогатились. Порог 'partial' НЕ заведён отдельным +# статусом scrape_runs.status — это потребовало бы миграции (DROP+ADD CHECK constraint, +# 051_scrape_runs_extend.sql) и обучило бы новому значению ещё 4 места (Literal-фильтр +# admin API, хардкод статусов фронта, оба IN-списка сторожей) — тот же класс "оборванной +# проводки", из-за которого заведён #2686/ban_kind. Вместо статуса — тот же диагноз, что и +# у ban_kind: causa в тексте `error`, терминальный статус один ('failed'). 0.15..0.5 — +# та же 'failed', но с другой формулировкой причины ("деградировал", не "провалился"), чтобы +# оператор видел разницу читая error, не только status. +# +# mark_backfill_finished (единственный писатель "attempted"/"failed" на верхнем уровне +# counters) живёт только в app.services.scrape_runs — здесь эта проверка сейчас неактивна +# ни для одного реального вызывающего, но kit-копия держится байт-эквивалентной app-копии +# (см. docstring модуля), и будущий kit-native job с тем же словарём получит её даром. +FAILED_RATIO_FAILED_THRESHOLD = 0.5 +FAILED_RATIO_DEGRADED_THRESHOLD = 0.15 +# Минимум попыток, при котором доля вообще что-то значит — иначе 1 отказ из 2 (=0.5) +# палит статус на шуме единичного случая. То же рассуждение и то же число, что у +# _PHASE_MIN_ATTEMPTS (см. выше). +_FAILED_RATIO_MIN_ATTEMPTS = _PHASE_MIN_ATTEMPTS + + +def _failed_ratio_too_high(counters: Mapping[str, Any]) -> str | None: + """Прогон, у которого доля отказов слишком велика, даже если что-то собрано. + + Возвращает текст причины (для error) либо None. Читает ГОЛЫЕ ключи "attempted"/ + "failed" (без фазового префикса) — сейчас это словарь только у четырёх + detail-backfill'ов (avito/yandex/domclick/newbuilding_enrich), все идут через + mark_backfill_finished → mark_done. `attempted < _FAILED_RATIO_MIN_ATTEMPTS` или + отсутствие любого из ключей → None (нечем/не о чём судить — счётчики либо не + заполнены, либо принадлежат другому источнику со своим словарём). + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и у #2625/#2700/#2764. + """ + attempted = _pick_int(counters, "attempted") + failed = _pick_int(counters, "failed") + if attempted is None or failed is None or attempted < _FAILED_RATIO_MIN_ATTEMPTS: + return None + ratio = failed / max(attempted, 1) + if ratio >= FAILED_RATIO_FAILED_THRESHOLD: + verb = "провалился" + elif ratio >= FAILED_RATIO_DEGRADED_THRESHOLD: + verb = "деградировал" + else: + return None + return ( + f"failed-ratio-honest-status: сбор {verb} — {failed} из {attempted} попыток " + f"отказали (доля {ratio:.0%}); формально ненулевой результат этого не искупает. " + f"Причина НЕ установлена — статус 'failed' без диагноза" + ) + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -287,13 +367,22 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926). Приоритет ключей: - - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched) - - new_count ← 'new_count' (если уже есть) иначе 'lots_inserted' + - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched / + rows_inserted / processed) + - new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted' + (первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex, + CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала + new_count=0 у трёх подряд cian_full_load при реально сохранённых + saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted' + у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ, + которым yandex_newbuilding_sweep сообщает число upsert'ов. Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). """ - return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted") + return _run_result_count(counters), _pick_int( + counters, "new_count", "lots_inserted", "saved_inserted", "rows_inserted" + ) def _alert_if_consecutive_failures(db: Session, source: str) -> None: @@ -632,6 +721,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет». + + honest-run-status: там же — отказ называть успехом прогон с высокой долей отказов, + даже если собрано > 0 (см. _failed_ratio_too_high). Отличие от #2625/#2700: те два + смотрят на «всё или ничего» (все якоря / вся фаза), этот — на ДОЛЮ отказов у + detail-backfill'ов, где ни один из первых двух признаков не матчит форму counters. """ did_nothing = _sweep_run_did_nothing(counters) if did_nothing is not None: @@ -643,6 +737,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: logger.error("%s run_id=%d", phase_dead, run_id) mark_failed(db, run_id, phase_dead, counters) return + ratio_bad = _failed_ratio_too_high(counters) + if ratio_bad is not None: + logger.error("%s run_id=%d", ratio_bad, run_id) + mark_failed(db, run_id, ratio_bad, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( From 8fcec9f12ef7764ec78cbab81033a7701c3e52e8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:08:04 +0300 Subject: [PATCH 42/60] fix(tradein/observability): stop basic_auth 401 and RetryError GlitchTip noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 83% of tracker issues (7460 total) were pure noise drowning real signal: - basic_auth 401 (3738 issues, 2019 distinct titles) — ops/glitchtip-auth- forwarder sent EVERY 401 from bots scanning gendsgn.ru (GET /wp-admin/ install.php etc.) as an individual GlitchTip event, remote_ip baked into message/tags inflated cardinality. Not an application error — expected bot-scan traffic against a basic_auth-protected site. - RetryError (2462 issues) — geocoder.py's three tenacity @retry-wrapped Nominatim helpers (lookup/suggest/reverse) raised tenacity.RetryError on exhaustion without reraise=True; RetryError.__str__() embeds a Future repr() with a memory address that differs every call, so GlitchTip grouped each exhausted retry as a distinct issue instead of one. Fix at the source, not post-hoc issue cleanup: - forwarder.py: before_send drops events tagged event_type in {basic_auth_failed, basic_auth_storm}; forwarder's own capture_exception (real script bugs) carries no such tag and passes through untouched. - geocoder.py: reraise=True on all three @retry decorators — propagates the real underlying exception (stable type + stacktrace) instead of the unstable RetryError wrapper. - sentry_scrub.stabilize_retry_error_fingerprint: belt-and-suspenders before_send hook, composed into both app/main.py and scheduler_main.py (geocoder runs in both processes — FastAPI request path and the overnight geocode_missing_listings batch). Collapses any RetryError that still slips through into one persistent issue per cause-exception type name only — never IP/address/listing-id. Content-ful categories (OperationalError, city-sweep, harvest_quarter, cian/avito/yandex sweep failures, scrape_freshness_check — ~700 issues) are untouched: filters key off event_type tag / exception type name only. --- ops/glitchtip-auth-forwarder/forwarder.py | 43 +++++++++ .../test_forwarder.py | 77 ++++++++++++++++ tradein-mvp/backend/app/main.py | 23 +++-- .../backend/app/observability/sentry_scrub.py | 37 ++++++++ tradein-mvp/backend/app/scheduler_main.py | 21 ++++- tradein-mvp/backend/app/services/geocoder.py | 17 +++- .../backend/tests/test_sentry_scrub.py | 92 +++++++++++++++++++ 7 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 ops/glitchtip-auth-forwarder/test_forwarder.py diff --git a/ops/glitchtip-auth-forwarder/forwarder.py b/ops/glitchtip-auth-forwarder/forwarder.py index 8fc3c5b4..79c782f3 100644 --- a/ops/glitchtip-auth-forwarder/forwarder.py +++ b/ops/glitchtip-auth-forwarder/forwarder.py @@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta Throttle: при >10 401 events за 60s — однократный digest event (чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются. +before_send=_drop_basic_auth_noise (glitchtip-noise фикс): все события отсюда +дропаются перед отправкой в GlitchTip — 401 от неаутентифицированного запроса +не ошибка сервиса, это боты сканируют закрытый basic_auth'ом сайт. Раньше это +был крупнейший источник шума в трекере (3 738 issue). Скрипт по-прежнему тэйлит +лог и печатает `[forwarder] 401 event sent: ...` в stdout (docker logs) — просто +больше не шлёт эти события в issue-трекер. Смотри `_drop_basic_auth_noise` docstring. + Реальный Caddy JSON access log (v2) структура: { "level": "info", @@ -73,6 +80,41 @@ _shutdown = False _last_exc_sent: float = 0.0 _EXC_THROTTLE_S: float = 300.0 +# event_type-теги, которыми emit_event/emit_digest помечают КАЖДОЕ отправляемое +# событие (см. scope.set_tag("event_type", ...) ниже) — используются как ключ +# для before_send-фильтра. +_BASIC_AUTH_EVENT_TYPES = frozenset({"basic_auth_failed", "basic_auth_storm"}) + + +def _drop_basic_auth_noise(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg] + """before_send-фильтр: 401 неаутентифицированного basic_auth-запроса — НЕ + ошибка сервиса, а expected-поведение сканеров-ботов, ломящихся в закрытый + basic_auth'ом gendsgn.ru (`GET /wp-admin/install.php` и подобное). До этого + фикса emit_event/emit_digest слали КАЖДЫЙ такой 401 individual-событием (или + storm-digest) в GlitchTip — remote_ip в message/тегах раздувал кардинальность + (3 738 issue, 2 019 различных заголовков, топ — 222 события на «GET + /wp-admin/install.p…»), топя содержательные алерты (OperationalError, sweep + failures) в шуме сканеров. + + Дропаем НА ИСТОЧНИКЕ (before_send), не постфактум-чисткой issue-трекера — + так шум не появляется вообще, а не изредка удаляется руками. Фильтруем по + тегу `event_type`, который ставят ТОЛЬКО emit_event/emit_digest — необработанные + исключения самого форвардера (`capture_exception` в конце `main()`, реальный + баг скрипта) этот тег не несут и проходят фильтр как есть (см. `except + Exception` ниже в `main()`). + """ + tags = event.get("tags") + event_type = None + if isinstance(tags, dict): + event_type = tags.get("event_type") + elif isinstance(tags, list): + # sentry_sdk в некоторых версиях сериализует tags как list[tuple[str, str]] + # вместо dict — на всякий случай поддерживаем обе формы. + event_type = dict(tags).get("event_type") if tags else None + if event_type in _BASIC_AUTH_EVENT_TYPES: + return None + return event + def _signal_handler(signum: int, frame: object) -> None: global _shutdown @@ -221,6 +263,7 @@ def main() -> None: traces_sample_rate=0.0, attach_stacktrace=False, send_default_pii=False, + before_send=_drop_basic_auth_noise, # Отключаем интеграции которые не нужны тонкому sidecar default_integrations=False, ) diff --git a/ops/glitchtip-auth-forwarder/test_forwarder.py b/ops/glitchtip-auth-forwarder/test_forwarder.py new file mode 100644 index 00000000..a9c089b3 --- /dev/null +++ b/ops/glitchtip-auth-forwarder/test_forwarder.py @@ -0,0 +1,77 @@ +"""Тесты для `_drop_basic_auth_noise` (before_send-фильтр, glitchtip-noise). + +Раньше форвардер слал КАЖДЫЙ basic_auth 401 (сканеры-боты, ломящиеся в закрытый +basic_auth'ом gendsgn.ru) individual-событием в GlitchTip — 3 738 issue, 2 019 +различных заголовков (remote_ip раздувал кардинальность), топя содержательный +сигнал. `_drop_basic_auth_noise` дропает эти события НА ИСТОЧНИКЕ (before_send), +но НЕ должен трогать unhandled-ошибки самого форвардера (реальный баг скрипта — +`capture_exception` без `event_type`-тега, аналог "500 должен пройти"). +""" + +from __future__ import annotations + +import os + +# DSN обязателен на module-level (`os.environ["GLITCHTIP_DSN"]`, fail-fast) — задаём +# ДО импорта forwarder.py, иначе импорт падает KeyError. +os.environ.setdefault("GLITCHTIP_DSN", "http://test@localhost/1") + +from forwarder import _BASIC_AUTH_EVENT_TYPES, _drop_basic_auth_noise + + +def test_drops_individual_basic_auth_401() -> None: + """emit_event() тегирует event_type=basic_auth_failed — 401 от бота-сканера, + не ошибка сервиса, должен быть отброшен (return None).""" + event = { + "tags": {"event_type": "basic_auth_failed", "remote_ip": "95.165.147.218"}, + "message": "basic_auth 401 — GET /wp-admin/install.php from 95.165.147.218", + } + assert _drop_basic_auth_noise(event, {}) is None + + +def test_drops_basic_auth_storm_digest() -> None: + """emit_digest() тегирует event_type=basic_auth_storm — тоже 401-класс, тоже + не ошибка сервиса, дропаем.""" + event = { + "tags": {"event_type": "basic_auth_storm"}, + "message": "basic_auth storm — 15 failed attempts in 60s", + } + assert _drop_basic_auth_noise(event, {}) is None + + +def test_drops_when_tags_serialized_as_list_of_tuples() -> None: + """Некоторые версии sentry_sdk сериализуют tags как list[tuple[str, str]] + вместо dict — фильтр обязан поддерживать обе формы.""" + event = {"tags": [("event_type", "basic_auth_failed")]} + assert _drop_basic_auth_noise(event, {}) is None + + +def test_passes_through_forwarder_own_crash() -> None: + """500-аналог: unhandled exception самого форвардера (capture_exception в + конце main(), реальный баг скрипта — напр. PermissionError на STATE_FILE) не + несёт event_type-тег → должен пройти НЕТРОНУТЫМ, не быть молча проглоченным + вместе с ботовым шумом.""" + event = { + "level": "error", + "exception": {"values": [{"type": "PermissionError", "value": "denied"}]}, + } + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_passes_through_event_without_tags() -> None: + event: dict = {"message": "something unrelated"} + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_passes_through_unrelated_tag_value() -> None: + event = {"tags": {"event_type": "something_else"}} + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_basic_auth_event_types_are_exactly_the_two_emitters_use() -> None: + """Явная фиксация словаря — emit_event → basic_auth_failed, + emit_digest → basic_auth_storm (см. forwarder.py).""" + assert _BASIC_AUTH_EVENT_TYPES == frozenset({"basic_auth_failed", "basic_auth_storm"}) diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 347cad8c..29f99749 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -66,17 +66,28 @@ logging.getLogger("httpx").setLevel(logging.WARNING) # worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/ # frontend), отдельного broker нет → мониторить нечего. if settings.glitchtip_dsn: - from app.observability.sentry_scrub import redact_telegram_bot_token + from app.observability.sentry_scrub import ( + redact_telegram_bot_token, + stabilize_retry_error_fingerprint, + ) def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None: - """Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) — - см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск: - теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке - sendMessage, а include_local_variables=False ниже — первый рубеж защиты).""" + """Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) + + RetryError fingerprint-стабилизация (glitchtip-noise) — см. + app/tgbot_main.py._before_send (та же композиция без последнего шага, + тот бот geocoder не зовёт). PII/token — тот же риск: теперь этот процесс + тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а + include_local_variables=False ниже — первый рубеж защиты. RetryError — + этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse), + которые ретраят Nominatim через tenacity; см. + sentry_scrub.stabilize_retry_error_fingerprint.""" scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type] if scrubbed is None: return None - return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value] + detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type] + if detokened is None: + return None + return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value] sentry_sdk.init( dsn=settings.glitchtip_dsn, diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 51c01e94..932042d5 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -174,3 +174,40 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No if not isinstance(event, dict): return event return _redact_strings(event) # type: ignore[return-value] + + +# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ──────── +# tenacity.RetryError.__str__() тащит repr() последнего Future +# (`RetryError[]`) — +# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py +# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim +# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим +# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ +# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True` +# теперь пробрасывает реальное исключение вместо RetryError — это ГЛАВНЫЙ фикс. +# Функция ниже — belt-and-suspenders: ловит RetryError, если он всё же всплывёт +# (ретрай-код, который забыли/не смогли пометить reraise=True), и схлопывает его +# в ОДИН persistent issue per (класс исключения-причины), а не тысячу. Fingerprint +# собран ТОЛЬКО из стабильных данных — имя типа исключения-причины (небольшой +# фиксированный словарь вроде "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ +# переменных данных запроса (адрес, IP, id объявления и т.п.), иначе проблема +# повторится в других терминах. +def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None: + """before_send-хук: схлопывает tenacity.RetryError в один persistent issue. + + Определяет тип exception через `hint["exc_info"]` (реальный объект + исключения, тот же контракт что sentry_sdk передаёт в before_send) — не + парсит уже сериализованный event dict, надёжнее к изменениям формата SDK. + Не-RetryError события возвращает без изменений (OperationalError, алерты + scraper sweep'ов и т.п. фильтр не трогает). + """ + if not isinstance(event, dict): + return event + exc_info = hint.get("exc_info") if isinstance(hint, dict) else None + exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None + if exc_value is None or type(exc_value).__name__ != "RetryError": + return event + cause = exc_value.__cause__ or exc_value.__context__ + cause_type = type(cause).__name__ if cause is not None else "Unknown" + event["fingerprint"] = ["retry-exhausted", cause_type] + return event diff --git a/tradein-mvp/backend/app/scheduler_main.py b/tradein-mvp/backend/app/scheduler_main.py index 3bb8af4a..e5f93ed0 100644 --- a/tradein-mvp/backend/app/scheduler_main.py +++ b/tradein-mvp/backend/app/scheduler_main.py @@ -44,7 +44,24 @@ if settings.glitchtip_dsn: from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration - from app.observability.sentry_scrub import scrub_pii_event + from app.observability.sentry_scrub import ( + scrub_pii_event, + stabilize_retry_error_fingerprint, + ) + + def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg] + """PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise). + + Этот процесс гоняет `geocode_missing_listings` (ночной batch, сотни + адресов за прогон) — @retry-декорированные Nominatim-хелперы + (app/services/geocoder.py) на исчерпанных ретраях исторически плодили + по отдельному GlitchTip issue на КАЖДЫЙ адрес (RetryError.__str__() + тащит нестабильный repr() Future). См. sentry_scrub docstring. + """ + scrubbed = scrub_pii_event(event, hint) + if scrubbed is None: + return None + return stabilize_retry_error_fingerprint(scrubbed, hint) sentry_sdk.init( dsn=settings.glitchtip_dsn, @@ -52,7 +69,7 @@ if settings.glitchtip_dsn: release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=0.0, send_default_pii=False, - before_send=scrub_pii_event, + before_send=_before_send, integrations=[ SqlalchemyIntegration(), HttpxIntegration(), diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 7785116a..6b3d9cc7 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -726,7 +726,16 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No return oblast_fallback -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) +# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях +# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future +# (`` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip +# группирует по этому нестабильному тексту → одна и та же причина (Nominatim +# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев +# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение +# (httpx.HTTPStatusError/TimeoutException) — стабильный тип+стек, группируется +# нормально. Дополнительная страховка — stabilize_retry_error_fingerprint +# (app/observability/sentry_scrub.py) на случай если RetryError всплывёт откуда-то ещё. +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True) async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None: """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy. @@ -925,7 +934,8 @@ async def _nominatim_query_city_aware( return _dedupe_nominatim_items(ekb_data, bare_data)[:limit] -@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) +# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум). +@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True) async def _nominatim_suggest( query: str, limit: int = 8, city_hint: str | None = None ) -> list[GeocodeSuggestion]: @@ -1654,7 +1664,8 @@ def _format_reverse_address(addr: dict) -> str | None: return ", ".join(parts) -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) +# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум). +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True) async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None: """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon. diff --git a/tradein-mvp/backend/tests/test_sentry_scrub.py b/tradein-mvp/backend/tests/test_sentry_scrub.py index 1e8d8ba5..242c03d4 100644 --- a/tradein-mvp/backend/tests/test_sentry_scrub.py +++ b/tradein-mvp/backend/tests/test_sentry_scrub.py @@ -15,6 +15,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from app.observability.sentry_scrub import ( redact_telegram_bot_token, scrub_pii_event, + stabilize_retry_error_fingerprint, ) @@ -259,3 +260,94 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None: assert out["request"]["data"]["client_phone"] == "[REDACTED]" frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"] assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url + + +# ── RetryError fingerprint stabilization (glitchtip-noise, #) ─ +# +# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address +# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в +# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в +# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина +# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере). +# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and- +# suspenders слой (основной фикс — reraise=True), и по контракту before_send: +# 401-класс (RetryError) схлопывается, содержательные категории (500-подобный +# generic Exception, OperationalError) проходят НЕТРОНУТЫМИ. + +from tenacity import RetryError # noqa: E402 + + +def _hint_for(exc: BaseException) -> dict: + """Строит hint в форме, которую sentry_sdk реально передаёт в before_send — + `exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint + полагается именно на эту форму, не на уже сериализованный event dict).""" + return {"exc_info": (type(exc), exc, exc.__traceback__)} + + +def _raise_retry_error_from(cause: BaseException) -> RetryError: + try: + raise cause + except type(cause) as caught: + try: + raise RetryError(None) from caught + except RetryError as retry_exc: + return retry_exc + + +def test_stabilize_retry_error_sets_stable_fingerprint() -> None: + """RetryError коллапсится в persistent issue по имени типа причины — НЕ по + нестабильному str(RetryError) (repr() Future с memory address).""" + exc = _raise_retry_error_from(TimeoutError("Nominatim timed out")) + out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc)) + assert out is not None + assert out["fingerprint"] == ["retry-exhausted", "TimeoutError"] + + +def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None: + """Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только + фиксированное имя типа исключения-причины (маленький словарь: + HTTPStatusError/ConnectTimeout/TimeoutError/...).""" + exc = _raise_retry_error_from( + ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345") + ) + out = stabilize_retry_error_fingerprint({}, _hint_for(exc)) + assert out is not None + fingerprint_text = " ".join(out["fingerprint"]) + assert "95.165.147.218" not in fingerprint_text + assert "12345" not in fingerprint_text + assert out["fingerprint"] == ["retry-exhausted", "ValueError"] + + +def test_stabilize_retry_error_leaves_operational_error_untouched() -> None: + """401-аналог задачи: OperationalError — содержательная категория (реальный + сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с + шумом).""" + from sqlalchemy.exc import OperationalError + + exc = OperationalError("SELECT 1", {}, Exception("connection refused")) + event = {"level": "error", "message": "db connection failed"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None: + """500-аналог задачи: обычное необработанное исключение (не RetryError) + проходит без изменений.""" + exc = RuntimeError("scraper city-sweep failed") + event = {"level": "error"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_no_exc_info_untouched() -> None: + """capture_message-based события (нет exc_info) — фильтр не трогает, напр. + scrape_runs.py consecutive-failure алерты (content-ful, должны доходить).""" + event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"} + out = stabilize_retry_error_fingerprint(dict(event), {}) + assert out == event + + +def test_stabilize_retry_error_handles_non_dict_event() -> None: + assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type] From c75206c348bc4ec35f2d51b95215046ac66ec8d9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:08:09 +0300 Subject: [PATCH 43/60] fix(tradein/geocoder): local houses fallback + downgrade DaData CLEAN-disabled noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28/1084 прод-оценок имели lat IS NULL — гарантированный ноль аналогов, клиент не получал оценку вовсе. Дом уже был в houses (скрейпленные листинги), но не резолвился ни geoportal/cad_buildings, ни Nominatim: разговорное/усечённое имя улицы («Онуфриева» вместо ГАР-каноничного «Начдива Онуфриева») или отсутствующий в вводе корпус («49» вместо реального «49к1»). Добавлен последний тир geocode() с двумя defensive-допущениями (суффиксный матч улицы + опциональная догадка «номер+к1») — при любой неоднозначности возвращает None, а не гадает; проверено живыми прод-адресами (Онуфриева/Хрустальногорская резолвятся, Крестинского корректно остаётся неоднозначным — два разных дома в houses под одним номером). Отдельно: HTTP 403 «услуга CLEAN выключена на аккаунте» логировался как ERROR на каждый /estimate (164 события) — это статичная конфигурация аккаунта, а не сбой; понижено до WARNING (первый раз за процесс) + DEBUG на повторы, чтобы ERROR продолжал значить настоящую проблему. --- tradein-mvp/backend/app/schemas/trade_in.py | 7 + tradein-mvp/backend/app/services/dadata.py | 32 +- tradein-mvp/backend/app/services/estimator.py | 1 + tradein-mvp/backend/app/services/geocoder.py | 255 +++++++++++- .../backend/tests/services/test_dadata.py | 37 +- .../test_geocoder_local_houses_fallback.py | 368 ++++++++++++++++++ 6 files changed, 689 insertions(+), 11 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 2c29d640..d7666f84 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -223,6 +223,13 @@ class AggregatedEstimate(BaseModel): # UI (снизить доверие / переспросить город), НЕ персистится в БД # (ephemeral, только для текущего POST /estimate ответа). target_city_ambiguous: bool = False + # #2626: True если координаты дал ПОСЛЕДНИЙ тир geocode() — fallback на `houses` + # (см. `app.services.geocoder._local_houses_match`), а не Nominatim/geoportal/ + # cadastral. Значит адрес пользователя не совпал буквально (разговорное/усечённое + # имя улицы или отсутствующий корпус), но был однозначно сопоставлен с домом из + # скрейпленных листингов. Честный сигнал для UI («адрес уточнён автоматически»), + # НЕ персистится в БД (ephemeral, как и `target_city_ambiguous`). + target_address_refined: bool = False sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr'] data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг # абсолютный timestamp самого свежего парсинга аналогов diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index 8a5b9c9f..1f68abf7 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -37,6 +37,12 @@ DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/sugge _DADATA_TIMEOUT_S = 8.0 _DADATA_SUGGEST_TIMEOUT_S = 5.0 +# Троттлинг WARNING «услуга CLEAN выключена на аккаунте» (#dadata-403-noise) — +# статичная конфигурация аккаунта, не транзиентный сбой. Первый раз за процесс +# логируется на WARNING, дальше — DEBUG, чтобы не заливать логи одним и тем же +# сообщением на каждый /estimate (было: logger.error на каждый запрос). +_clean_disabled_warned = False + @dataclass(frozen=True, slots=True) class DadataAddressResult: @@ -172,15 +178,29 @@ async def clean_address(address: str) -> DadataAddressResult | None: # но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ # поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback # (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена. + # + # Это НЕ сбой (аккаунт постоянно живёт с выключенной услугой, enrich_address уже + # graceful-деградирует на suggest — см. ниже) — раньше это било logger.error на + # КАЖДЫЙ пользовательский запрос (164 события/запрос-волна в проде), из-за чего + # ERROR переставал значить «настоящий сбой». WARNING один раз за процесс (дальше — + # DEBUG) сохраняет видимость причины без шума на каждый /estimate. if status == 403 and ( "disabled" in body_preview.lower() or "feature" in body_preview.lower() ): - logger.error( - "dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте " - "(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или " - "полагайся на suggest-fallback (enrich_address). Ответ: %r", - body_preview, - ) + global _clean_disabled_warned + if not _clean_disabled_warned: + logger.warning( + "dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте " + "(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или " + "полагайся на suggest-fallback (enrich_address). Ответ: %r " + "(повторы этого сообщения в рамках процесса логируются на DEBUG)", + body_preview, + ) + _clean_disabled_warned = True + else: + logger.debug( + "dadata: HTTP 403 CLEAN disabled (уже предупреждено WARNING в этом процессе)" + ) else: logger.error( "dadata: HTTP %d — auth/secret rejected. " diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 893da182..c41dbc19 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -4762,6 +4762,7 @@ async def estimate_quality( target_lat=geo.lat, target_lon=geo.lon, target_city_ambiguous=geo.city_ambiguous, + target_address_refined=geo.address_refined, sources_used=sources_used, data_freshness_minutes=freshness_min, last_scraped_at=last_scraped_at, diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 7785116a..8d798476 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -44,6 +44,19 @@ class GeocodeResult: # результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог # понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`. city_ambiguous: bool = False + # #2626: True если результат дал ПОСЛЕДНИЙ локальный тир — fallback на `houses` + # (скрейпленные листинги, см. `_local_houses_match`) — а не Nominatim/geoportal/ + # cadastral. Срабатывает, когда в тексте адреса опечатка/сокращение улицы + # («Онуфриева» вместо канонического «Начдива Онуфриева» в ГАР) или отсутствует + # корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по + # нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён + # автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`. + # Известный предел: `geocode_cache` НЕ хранит этот флаг (схему не трогаем) — + # на повторный запрос ТОГО ЖЕ сырого адреса из кэша координаты корректные, но + # `address_refined` вернётся `False` (та же судьба у `city_ambiguous` при + # cache-hit — см. `_geocode_resolve`, восстанавливается `replace()` из + # текущего вызова, а не из кэша). + address_refined: bool = False # ── EKB bounding boxes ─────────────────────────────────────────────────────── @@ -1188,12 +1201,13 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge ВНИМАНИЕ, цепочки различаются — не путать: * `geocode()` : geoportal → cadastral → `_cadastral_forward_sync` - → Nominatim → None. Тира DaData тут НЕТ. + → Nominatim → `_local_houses_match` (#2626, houses-фолбэк) + → None. Тира DaData тут НЕТ. * `suggest()` : cadastral → DaData → Nominatim (единственный вызов `_dadata_suggest`). То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`) - адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None — - оценка не строится. Это сознательный выбор: честный отказ вместо + адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку, + даёт None — оценка не строится. Это сознательный выбор: честный отказ вместо уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет — координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776` → `api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`). @@ -1310,6 +1324,210 @@ def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSugge ) +# ── Local `houses` fallback (#2626) — последний тир geocode() ─────────────── +# Мотивация: 28/1084 прод-оценок с lat IS NULL — гарантированный ноль аналогов, +# клиент не получает оценку вовсе. Живые примеры (адрес пользователя → ГАР/houses): +# «ул Крестинского, д 49» — «49» голого нет в houses, есть только «49к1» +# (корпус потерян при вводе, houses id 9980 «улица Крестинского, 49к1»); +# «ул Онуфриева, д 24» — houses называет улицу «Начдива Онуфриева» (ГАР), +# пользователь пишет только последнее слово имени. +# Дом уже ЕСТЬ в `houses` (скрейпленные листинги avito/cian/derived/yandex) с +# координатами — Nominatim и ЕКБ-реестры (geoportal/cad_buildings) эти формы не +# резолвят, а houses чаще содержит именно то написание, которым реально пользуются +# люди (агрегировано из объявлений, а не из официального ГАР). +# +# Номер дома в `houses.address` — СВОБОДНЫЙ текст источников (avito/cian/derived/ +# yandex_valuation): «улица X, 49к1» / «X ул.,88/2» / «X, 44» — БЕЗ единого формата +# и без «д./дом»-маркера, в отличие от `gendesign_cad_buildings.readable_address`. +# Поэтому здесь — собственная, более широкая нормализация номера (со слэшем +# «88/2» и корпусом «49к1»), а НЕ переиспользование `_HOUSE_NUM`/`_norm_house` +# (те заточены под geoportal/cad_buildings реестры, где «/N» и «корпус N» реже). +_LOCAL_HOUSE_TOKEN_RE = re.compile( + r"(\d+(?:\s*/\s*\d+)?(?:\s*-?\s*(?:к|корп\.?|корпус)\.?\s*-?\s*\d+)?(?:\s*-?\s*[а-яё])?)", + re.IGNORECASE, +) + + +def _norm_local_house(raw: str) -> str: + """Канон номера дома для houses-фолбэка. + + «49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»; «35А» → «35а». + """ + s = raw.strip().lower() + s = re.sub(r"\s+", "", s) + s = re.sub(r"корпус|корп\.?", "к", s) + s = re.sub(r"-(к\d+)", r"\1", s) + s = re.sub(r"-([а-яё])$", r"\1", s) + return s + + +def _extract_local_house_token(address: str) -> str | None: + """Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов, + которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у + `_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение — номер дома в русском + адресе почти всегда в хвосте строки. None, если цифр нет вовсе. + """ + s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.") + if not s: + return None + matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s)) + if not matches: + return None + return _norm_local_house(matches[-1].group(1)) + + +# Маркеры района/города/страны — обрезаются из `houses.address` перед сравнением +# улицы (`_clean_local_house_street`). Хвостовое сравнение (см. ниже) и без этого +# устойчиво к ЛИШНЕМУ префиксу («р-н Ленинский, мкр. Юго-Западный, улица X» всё +# равно оканчивается на «... улица x» и матчит суффиксом), но тип улицы ПОСЛЕ +# имени («Хрустальногорская ул.») ломает суффикс без явной зачистки типа. +# Хвостовой якорь — lookahead на пробел/конец строки, а НЕ `\b`: «ул.» в самом +# конце сегмента (частая форма в houses.address) заканчивается точкой, а `\b` +# сразу после точки на границе строки не срабатывает (оба «символа» не-\w) — +# тип-слово матчилось бы БЕЗ точки, точка оставалась бы висеть («хрустальногорская .») +# и ломала «хвостовое» сравнение улицы (реальный прод-кейс: id 13080 houses). +_LOCAL_HOUSE_STREET_TYPE_RE = re.compile(rf"\b(?:{_STREET_TYPE})\.?(?=\s|$)", re.IGNORECASE) + + +def _clean_local_house_street(segment: str) -> str: + """«Хрустальногорская ул.» / «улица Начдива Онуфриева» → «хрустальногорская» / + «начдива онуфриева»: lower, без типа улицы, схлопнутые пробелы. + + Общая нормализация и для запроса пользователя (уже typeless из + `_parse_street_house`, но повторный проход — no-op), и для `houses.address`. + """ + s = _LOCAL_HOUSE_STREET_TYPE_RE.sub(" ", segment.lower()) + return " ".join(s.split()) + + +def _row_local_house(address: str) -> tuple[str, str] | None: + """Разбирает ОДНУ строку `houses.address` на (street_clean, house_norm). + + Номер дома — ПОСЛЕДНИЙ через-запятую сегмент (во всех живых формах: «X, 49к1», + «X ул.,88/2», «X, 44»), СОВПАДЕНИЕ С НАЧАЛА этого сегмента (не всей строки) — + покрывает и «49к1» целиком, и «35к1 · р-н Академический» (хвостовой мусор + после номера отбрасывается). Известный неполный случай (не встретился в + выборке): номер дома БЕЗ запятой перед ним — вернёт None, строка просто не + станет кандидатом (не ложный матч). + """ + segments = [s.strip() for s in address.split(",") if s.strip()] + if len(segments) < 2: + return None + m = _LOCAL_HOUSE_TOKEN_RE.match(segments[-1]) + if not m: + return None + house_norm = _norm_local_house(m.group(1)) + street_norm = _clean_local_house_street(" ".join(segments[:-1])) + if not street_norm or not house_norm: + return None + return street_norm, house_norm + + +def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool: + """True если `query_street_norm` — «хвост» (последнее слово/слова) имени улицы + в `houses` — «онуфриева» находит «начдива онуфриева» (ГАР-каноничное имя), + регистронезависимо. Точное равенство тоже проходит (частый случай — короткие + однословные улицы, «Малышева» == «Малышева»).""" + return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm) + + +def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None: + """Последний локальный тир `geocode()` (#2626) — fallback на `houses` + (скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW). + + Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата. + Два независимых допущения, оба defensive (при неоднозначности — None, не гадаем): + + 1. Улица матчится «по хвосту» (`_street_tail_matches`) — ловит расхождение + разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в + houses («Начдива Онуфриева»). + 2. Номер дома — сперва точное совпадение; нет — пробуем `<номер>к1` (частый + случай: пользователь ввёл «49», у дома есть только корпус «49к1»). ЛЮБОЙ + шаг, где кандидатов больше одного (после дедупа по координатам — разные + source-строки ОДНОГО дома не в счёт), возвращает None — угадывать нельзя. + + SQL — дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на + `houses.address`, но тир последний и редкий — не на каждый запрос), вся + точная логика (суффикс улицы + равенство номера) — в Python, что и делает + её юнит-тестируемой без реальной БД (см. `test_geocoder_local_houses_fallback.py`). + """ + query_street_norm = _clean_local_house_street(street) + if not query_street_norm: + return None + query_house_norm = _norm_local_house(house) + if not query_house_norm: + return None + last_word = query_street_norm.split()[-1] + + try: + rows = db.execute( + text(""" + SELECT address, lat, lon + FROM houses + WHERE address ILIKE CAST('%' || :w || '%' AS text) + AND lat IS NOT NULL AND lon IS NOT NULL + """), + {"w": last_word}, + ).fetchall() + except Exception: + logger.warning( + "local houses fallback query failed for street=%r house=%r", + street, + house, + exc_info=True, + ) + return None + + def _candidates(house_norm: str) -> list[tuple[str, float, float]]: + out: list[tuple[str, float, float]] = [] + seen_coords: set[tuple[float, float]] = set() + for r in rows: + parsed = _row_local_house(str(r.address or "")) + if parsed is None: + continue + row_street_norm, row_house_norm = parsed + if row_house_norm != house_norm: + continue + if not _street_tail_matches(row_street_norm, query_street_norm): + continue + coord_key = (round(float(r.lat), 4), round(float(r.lon), 4)) # ~11m — дедуп источников + if coord_key in seen_coords: + continue + seen_coords.add(coord_key) + out.append((str(r.address), float(r.lat), float(r.lon))) + return out + + exact = _candidates(query_house_norm) + if len(exact) == 1: + addr, lat, lon = exact[0] + return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house") + if len(exact) > 1: + logger.info( + "local houses fallback: %d неоднозначных кандидата для %r %r — skip", + len(exact), + street, + house, + ) + return None + + # Точного номера нет — пробуем «<номер>к1» (корпус потерян при вводе), ТОЛЬКО + # если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»). + if query_house_norm.isdigit(): + corpus1 = f"{query_house_norm}к1" + guessed = _candidates(corpus1) + if len(guessed) == 1: + addr, lat, lon = guessed[0] + logger.info("local houses fallback: %r → корпус-1 %r (%s)", house, corpus1, addr) + return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house") + if len(guessed) > 1: + logger.info( + "local houses fallback: корпус-1 %r неоднозначен (%d кандидата) — skip", + corpus1, + len(guessed), + ) + return None + + def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None: """Reverse lookup via gendesign_cad_buildings FDW. @@ -1586,6 +1804,37 @@ async def _geocode_resolve( except Exception: logger.exception("nominatim geocoder failed") + # 4. Local `houses` fallback (#2626) — САМЫЙ ПОСЛЕДНИЙ тир, до возврата None. + # 28/1084 прод-оценок имели lat IS NULL (гарантированный ноль аналогов) — дом + # был в `houses` (скрейпленные листинги), но не в geoportal/cad_buildings и не + # резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий + # в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у + # geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск + # коллизии улица+дом с другим городом региона, что и мотивировал #2582). + if use_local_ekb and parsed is not None: + local_street, _parsed_house = parsed + local_house = _extract_local_house_token(address) or _parsed_house + hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house) + if hit is not None: + result = GeocodeResult( + lat=hit.lat, + lon=hit.lon, + full_address=hit.full_address, + provider="cache", # локальный DB-lookup, без внешнего HTTP — как geoportal + confidence="exact", + city_ambiguous=city_ambiguous, + address_refined=True, + ) + await asyncio.to_thread(_cache_put, db, addr_norm, result) + logger.info( + "geocode local houses fallback: %s → (%.5f, %.5f) [%s]", + addr_norm, + result.lat, + result.lon, + hit.full_address, + ) + return result + return None diff --git a/tradein-mvp/backend/tests/services/test_dadata.py b/tradein-mvp/backend/tests/services/test_dadata.py index 4b9a7464..a7670fc8 100644 --- a/tradein-mvp/backend/tests/services/test_dadata.py +++ b/tradein-mvp/backend/tests/services/test_dadata.py @@ -781,12 +781,19 @@ def _mock_enrich_transport( async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None: - """403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен).""" + """403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен). + + #dadata-403-noise: это статичная конфигурация аккаунта (не транзиентный сбой) — + логируется на WARNING (не ERROR), чтобы ERROR продолжал значить «настоящий сбой» + (раньше — logger.error на КАЖДЫЙ пользовательский запрос, 164 события в проде). + """ from app.services import dadata + dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle) + transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY) with _patch_settings(), _patch_async_client(transport): - with caplog.at_level(_logging.ERROR, logger="app.services.dadata"): + with caplog.at_level(_logging.WARNING, logger="app.services.dadata"): result = await dadata.clean_address("Екатеринбург, Малышева 4") assert result is None @@ -794,6 +801,32 @@ async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None: assert "Стандартизация" in text or "выключена" in text # Не должны обвинять токен при feature-disabled. assert "auth/secret rejected" not in text + # НЕ ERROR — статичная причина, не сбой (#dadata-403-noise). + assert not any(rec.levelno >= _logging.ERROR for rec in caplog.records) + + +async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog) -> None: + """Второй (и далее) 403 CLEAN-disabled за один процесс → DEBUG, не повторный WARNING. + + #dadata-403-noise: без троттлинга WARNING на каждый /estimate так же шумит логи, + как раньше шумел ERROR — цель фикса теряется наполовину. + """ + from app.services import dadata + + dadata._clean_disabled_warned = False + + transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY) + with _patch_settings(), _patch_async_client(transport): + with caplog.at_level(_logging.DEBUG, logger="app.services.dadata"): + first = await dadata.clean_address("Екатеринбург, Малышева 4") + caplog.clear() + second = await dadata.clean_address("Екатеринбург, Ленина 10") + + assert first is None + assert second is None + # Второй вызов — НИ ОДНОГО WARNING/ERROR (только DEBUG или тише). + assert not any(rec.levelno >= _logging.WARNING for rec in caplog.records) + assert dadata._clean_disabled_warned is True async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None: diff --git a/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py b/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py new file mode 100644 index 00000000..fef919a4 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py @@ -0,0 +1,368 @@ +"""Unit tests for the `houses` fallback tier of `geocode()` (#2626). + +Covers: +- `_norm_local_house`: normalization of corpus/slash house-number forms + («49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»). +- `_extract_local_house_token`: pulling the house-number token out of a raw + user address, WITH the corpus/slash suffix that `_parse_street_house`'s + `_HOUSE_NUM` drops. +- `_clean_local_house_street` / `_row_local_house`: extracting a comparable + (street, house) pair out of the free-text `houses.address` column (multiple + scraper source formats — avito/cian/derived/yandex_valuation). +- `_street_tail_matches`: «Онуфриева» finds «Начдива Онуфриева» (ГАР canonical + name), regardless of leading district/city noise. +- `_local_houses_match`: full tier with a mocked DB session — + exact number match, corpus-1 fallback guess («49» → «49к1»), and the + defensive "ambiguous → None" invariant (no guessing on >1 distinct match). +- `geocode()` wiring: local-houses tier is the LAST step, only reached when + cache/geoportal/cadastral/Nominatim all miss, and marks + `GeocodeResult.address_refined=True`. + +Real prod addresses (#2626, lat IS NULL in trade_in_estimates) are used as +regression fixtures: «ул Онуфриева, д 24» → «Начдива Онуфриева, 24к1», +«ул. Хрустальногорская, д. 88/2» → exact match, «ул Крестинского, д 49» → +genuinely ambiguous in prod data (two DIFFERENT buildings both stored as +«Крестинского, 49к1» — must NOT resolve, per the defensive "no guessing" rule). +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +from app.services.geocoder import ( # noqa: E402 + GeocodeSuggestion, + _clean_local_house_street, + _extract_local_house_token, + _local_houses_match, + _norm_local_house, + _row_local_house, + _street_tail_matches, + geocode, +) + +# ── _norm_local_house ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("49 к 1", "49к1"), + ("49-к1", "49к1"), + ("49 корпус 1", "49к1"), + ("49 корп. 1", "49к1"), + ("88 / 2", "88/2"), + ("88/2", "88/2"), + ("35А", "35а"), + ("13Б", "13б"), + ("13-б", "13б"), + ("44", "44"), + ], +) +def test_norm_local_house(raw: str, expected: str) -> None: + assert _norm_local_house(raw) == expected + + +# ── _extract_local_house_token ─────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("address", "expected"), + [ + ("ул Крестинского, д 49", "49"), + ("ул. Хрустальногорская, д. 88/2", "88/2"), + ("ул Онуфриева, д 24", "24"), + ("Крестинского 49к1", "49к1"), + ("8 Марта 204", "204"), # digit-leading street name doesn't confuse it + ("Малышева 30", "30"), + ], +) +def test_extract_local_house_token(address: str, expected: str) -> None: + assert _extract_local_house_token(address) == expected + + +def test_extract_local_house_token_none_for_garbage() -> None: + assert _extract_local_house_token("") is None + assert _extract_local_house_token("Екатеринбург") is None + + +# ── _clean_local_house_street / _street_tail_matches ──────────────────────── + + +def test_clean_local_house_street_strips_type_regardless_of_position() -> None: + """Тип улицы ДО имени («улица X») и ПОСЛЕ («X ул.») — оба зачищаются.""" + assert _clean_local_house_street("улица Начдива Онуфриева") == "начдива онуфриева" + assert _clean_local_house_street("Хрустальногорская ул.") == "хрустальногорская" + + +def test_street_tail_matches_onufrieva_finds_nachdiva_onufrieva() -> None: + """Ядро #2626: «Онуфриева» (как пишет пользователь) находит «Начдива + Онуфриева» (каноничное имя ГАР, как в houses.address).""" + assert _street_tail_matches("начдива онуфриева", "онуфриева") is True + + +def test_street_tail_matches_exact_equality() -> None: + assert _street_tail_matches("хрустальногорская", "хрустальногорская") is True + + +def test_street_tail_matches_rejects_non_suffix_substring() -> None: + """«Онуфриева» НЕ находит несвязанную улицу, где она — не хвостовое слово.""" + assert _street_tail_matches("онуфриева южная", "онуфриева") is False + + +# ── _row_local_house: разбор houses.address разных форматов источников ────── + + +@pytest.mark.parametrize( + ("row_address", "expected"), + [ + ( + "р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1", + ("р-н чкаловский мкр. ботанический крестинского", "49к1"), + ), + ("Хрустальногорская ул.,88/2", ("хрустальногорская", "88/2")), + ("ул. Начдива Онуфриева,24к2", ("начдива онуфриева", "24к2")), + ( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + ("р-н ленинский мкр. юго-западный начдива онуфриева", "24к1"), + ), + ("Крестинского, 44", ("крестинского", "44")), + # house-then-district order («·» separator, no comma before house) — + # match-from-start of the LAST comma-segment still finds the leading token. + ("улица Хрустальногорская, 35к1 · р-н Академический", ("хрустальногорская", "35к1")), + ], +) +def test_row_local_house(row_address: str, expected: tuple[str, str]) -> None: + assert _row_local_house(row_address) == expected + + +def test_row_local_house_none_without_house_segment() -> None: + """Нет запятой (номер дома не отделён сегментом) → None, не гадаем.""" + assert _row_local_house("Крестинского") is None + assert _row_local_house("") is None + + +# ── _local_houses_match: full tier, mocked db ──────────────────────────────── + + +def _make_row(address: str, lat: float, lon: float) -> MagicMock: + row = MagicMock() + row.address = address + row.lat = lat + row.lon = lon + return row + + +def _db_with_rows(rows: list[MagicMock]) -> MagicMock: + db = MagicMock() + db.execute.return_value.fetchall.return_value = rows + return db + + +def test_local_houses_match_exact_house_number() -> None: + """«88/2» точно совпадает с единственной строкой houses — возвращает её координаты.""" + db = _db_with_rows( + [ + _make_row("Хрустальногорская ул.,88", 56.79412, 60.498687), + _make_row("Хрустальногорская ул.,88/2", 56.793218, 60.497106), + ] + ) + + hit = _local_houses_match(db, "хрустальногорская", "88/2") + + assert hit is not None + assert isinstance(hit, GeocodeSuggestion) + assert hit.lat == pytest.approx(56.793218) + assert hit.lon == pytest.approx(60.497106) + assert hit.kind == "house" + + +def test_local_houses_match_street_tail_and_corpus1_guess() -> None: + """«Онуфриева, 24» (без «Начдива», без корпуса) → единственный «24к1» реестра.""" + db = _db_with_rows( + [ + _make_row( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + 56.802928, + 60.551696, + ), + _make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391), + _make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283), + ] + ) + + hit = _local_houses_match(db, "онуфриева", "24") + + assert hit is not None + assert hit.lat == pytest.approx(56.802928) + assert hit.lon == pytest.approx(60.551696) + + +def test_local_houses_match_no_corpus1_candidate_returns_none() -> None: + """Только «24к2»/«24к3» в реестре (нет «24к1») → фолбэк НЕ гадает, None.""" + db = _db_with_rows( + [ + _make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391), + _make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283), + ] + ) + + assert _local_houses_match(db, "онуфриева", "24") is None + + +def test_local_houses_match_ambiguous_exact_number_returns_none() -> None: + """Прод-кейс: «Крестинского, 49к1» встречается ДВАЖДЫ с РАЗНЫМИ координатами + (две разные строки houses) — неоднозначность, фолбэк не угадывает, None.""" + db = _db_with_rows( + [ + _make_row( + "р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1", + 56.789895, + 60.632464, + ), + _make_row("Екатеринбург, улица Крестинского, 49к1", 56.7952695, 60.610079), + ] + ) + + assert _local_houses_match(db, "крестинского", "49к1") is None + + +def test_local_houses_match_ambiguous_corpus1_guess_returns_none() -> None: + """«49» → «49к1»-кандидатов больше одного (разные координаты) → None.""" + db = _db_with_rows( + [ + _make_row("улица X, 49к1", 56.80, 60.60), + _make_row("улица X, 49к1", 56.81, 60.61), + ] + ) + + assert _local_houses_match(db, "x", "49") is None + + +def test_local_houses_match_deduplicates_same_building_different_sources() -> None: + """Один и тот же дом, две source-строки (avito+cian) с ПОЧТИ идентичными + координатами — НЕ считается неоднозначностью (дедуп по округлённым coords).""" + db = _db_with_rows( + [ + _make_row("улица X, 49к1", 56.800001, 60.600001), + _make_row("улица X, 49к1", 56.800002, 60.600002), # тот же дом, другой source + ] + ) + + hit = _local_houses_match(db, "x", "49к1") + + assert hit is not None + assert hit.lat == pytest.approx(56.800001) + + +def test_local_houses_match_no_guess_for_non_digit_house() -> None: + """Запрос уже с литерой/корпусом («35к3»), точного совпадения нет — корпус-1 + ДОГАДКА не пробуется (не «35к3к1»), результат None.""" + db = _db_with_rows([_make_row("улица X, 35к4", 56.80, 60.60)]) + + assert _local_houses_match(db, "x", "35к3") is None + + +def test_local_houses_match_returns_none_on_db_error() -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("connection lost") + + assert _local_houses_match(db, "онуфриева", "24") is None + + +# ── geocode() wiring — last-resort tier, sets address_refined ─────────────── + + +async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None: + """Cache/geoportal/cadastral/Nominatim все промахнулись → local-houses тир + вызывается ПОСЛЕДНИМ и помечает результат `address_refined=True`.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + full_address="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + lat=56.802928, + lon=60.551696, + kind="house", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=None), + patch("app.services.geocoder._cadastral_house_match", return_value=None), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "app.services.geocoder._local_houses_match", + return_value=hit, + ) as mock_local, + ): + result = await geocode("ул Онуфриева, д 24", db) + + assert result is not None + assert result.lat == pytest.approx(56.802928) + assert result.confidence == "exact" + assert result.address_refined is True + mock_local.assert_called_once() + + +async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None: + """geoportal-хит (обычный, точный ввод) НЕ помечается `address_refined` — + флаг честно относится ТОЛЬКО к houses-фолбэку.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="ул. Серова, д. 27, Екатеринбург", + full_address="ул. Серова, д. 27, Екатеринбург", + lat=56.81188, + lon=60.59739, + kind="house", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=hit), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._local_houses_match", + ) as mock_local, + ): + result = await geocode("Серова 27", db) + + assert result is not None + assert result.address_refined is False + mock_local.assert_not_called() + + +async def test_geocode_returns_none_when_local_houses_also_misses() -> None: + """Все тиры включая houses-фолбэк промахнулись → honest None (не выдумываем).""" + db = MagicMock() + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=None), + patch("app.services.geocoder._cadastral_house_match", return_value=None), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=None, + ), + patch("app.services.geocoder._local_houses_match", return_value=None) as mock_local, + ): + result = await geocode("ул Онуфриева, д 24", db) + + assert result is None + mock_local.assert_called_once() From 3f5f09939237353a52fb3e8f0be74424228df7a8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:44:31 +0300 Subject: [PATCH 44/60] =?UTF-8?q?fix(health):=20HEAD=20/health=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=BC=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D0=B5=D0=BD=D0=B4=D0=B5=20(Site=20Finder)=20+=20=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-разбор ветки fix/tradein-uptime-honest-green: 1. [HIGH] Прод-симптом `HEAD gendsgn.ru/health -> 405` обслуживает Site Finder (Caddyfile:60 `handle /health { reverse_proxy backend:8000 }`), а предыдущий коммит правил только tradein-mvp/backend, чей /health наружу не проксируется вообще. Добавлен @app.head("/health") в backend/app/main.py рядом с существующим @app.get — эмпирически подтверждено (uv run pytest): HEAD было 405, стало 200. tradein-mvp фикс не откачен (безвреден, годится для будущего internal-caller), но обвязан комментарием, что реальный прод-путь чинится не там. 2. [LOW] Response(status_code=200) без media_type отдавал HEAD без Content-Type, тогда как GET отдаёт application/json — расходится с заявленным в комментарии RFC 9110 §9.3.2. Добавлен media_type в обоих бэкендах; Content-Length сознательно не подгоняем под байты GET-ответа (payload header field, RFC разрешает опускать для HEAD) — не дублируем сборку payload ради байт-в-байт соответствия. Тесты: test_health_head_ok_no_body добавлен в backend/tests/test_health.py (Site Finder) — RED-check (git stash app/main.py) воспроизводит прод-баг 1:1: assert 405 == 200. tradein-mvp/backend/tests/test_health_endpoint.py дополнен проверкой Content-Type. uv run pytest — все зелёные. --- backend/app/main.py | 16 ++++++++++++++++ backend/tests/test_health.py | 18 ++++++++++++++++++ tradein-mvp/backend/app/main.py | 16 +++++++++++----- .../backend/tests/test_health_endpoint.py | 4 ++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index e0ac46cb..c779e335 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -508,3 +508,19 @@ async def health() -> dict[str, str]: "environment": settings.environment, "version": app.version, } + + +# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от +# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health +# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health { +# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на +# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor +# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по +# статусу. media_type="application/json" — Content-Type совпадает с GET; +# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы +# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload- +# заголовки (Content-Length) для HEAD, требует совпадения только заголовков +# представления (Content-Type). +@app.head("/health") +async def health_head() -> Response: + return Response(status_code=200, media_type="application/json") diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index c432abcf..a62f2567 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -9,3 +9,21 @@ def test_health() -> None: assert response.status_code == 200 body = response.json() assert body["status"] == "ok" + + +def test_health_head_ok_no_body() -> None: + """HEAD /health — то, что реально шлёт внешний uptime-monitor через Caddy + (`handle /health { reverse_proxy backend:8000 }`, Caddyfile:60), не GET. + + Starlette не добавляет HEAD автоматически к `@app.get()` (в отличие от + низкоуровневого `Route(methods=["GET"])`) — без явного `@app.head()` + прод-эндпоинт отдаёт 405 на HEAD. + """ + client = TestClient(app) + response = client.head("/health") + assert response.status_code == 200 + assert response.content == b"" + # RFC 9110 §9.3.2 — заголовки представления (Content-Type) должны совпадать + # с GET; Content-Length допустимо не совпадать (payload header field, MAY + # быть опущен для HEAD). + assert response.headers["content-type"] == "application/json" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 4f21a09a..8a48c7c3 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -212,13 +212,19 @@ def health() -> dict[str, str]: # FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от # raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health -# отдаёт 405, и внешний uptime-monitor (GlitchTip PING-тип, HEAD-запрос) не -# может отличить "жив" от "мёртв" по статусу. Тело для HEAD не отдаём — так -# требует HTTP-спека (RFC 9110 §9.3.2): у ответа те же заголовки, что у GET, -# но без body. +# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только +# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой +# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам — +# только pg_isready для postgres) — маршрут пока используется лишь тестами. +# Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder +# (backend/app/main.py, за Caddyfile `handle /health`), не здесь. +# media_type="application/json" — Content-Type совпадает с GET; Content-Length +# сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload) +# — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для +# HEAD, требует совпадения только заголовков представления (Content-Type). @app.head("/health") def health_head() -> Response: - return Response(status_code=200) + return Response(status_code=200, media_type="application/json") app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) diff --git a/tradein-mvp/backend/tests/test_health_endpoint.py b/tradein-mvp/backend/tests/test_health_endpoint.py index 1fa05757..be2d7fab 100644 --- a/tradein-mvp/backend/tests/test_health_endpoint.py +++ b/tradein-mvp/backend/tests/test_health_endpoint.py @@ -29,3 +29,7 @@ def test_health_head_ok_no_body() -> None: resp = client.head("/health") assert resp.status_code == 200 assert resp.content == b"" + # RFC 9110 §9.3.2 — HEAD должен вернуть те же заголовки представления + # (Content-Type), что и GET; Content-Length допустимо не совпадать (payload + # header field, MAY быть опущен для HEAD). + assert resp.headers["content-type"] == "application/json" From 8bce8cf5aea6bef89250ed276e92e14c4bff54a6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:44:48 +0300 Subject: [PATCH 45/60] fix(ci): fail-safe registry verification + real cache self-heal + honest health-check (#2841 R2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью R2 нашёл, что вся безопасность предыдущего фикса держалась на недоказанной поддержке act_runner'ом steps..outcome: если раннер его не заполняет, retry-шаг молча не бежит, continue-on-error проглатывает падение сборки, job зелёный — а деплой тянет старый :latest на прод. - Добавлен engine-agnostic verify-шаг после каждого retry (6 мест, deploy.yml + deploy-tradein.yml): `docker buildx imagetools inspect :` без continue-on-error. Не зависит от того, поддерживает ли раннер outcome — проверяет реальное состояние registry напрямую. Если ни build, ни retry реально не запушили образ — шаг падает и job честно FAILURE независимо от семантики outcome. - Вернул `cache-to` в retry-шаги (6 мест): без него битый buildcache-тег никогда не перезаписывался — retry всегда собирал без cache-to, значит cache-to не выполнялся НИКОГДА, и каждый следующий прогон снова падал на том же cache-from. Заявленное самолечение не работало ни разу. - Health-check в deploy.yml (main-стек) под `set -e` не мог упасть: `curl ... && break` — curl не последняя команда &&-списка, POSIX освобождает такие команды от errexit, цикл дохаживал до sleep (exit 0) даже если curl ни разу не отдал 200. Приведено к паттерну deploy-tradein.yml: явный флаг healthy + `exit 1` после цикла. Подтверждено локальным bash-репро (mock curl, всегда failure): старая версия — exit 0, новая — exit 1; позитивный сценарий не сломан. docker rm -f без -v в SSH-скриптах деплоя не тронут. --- .forgejo/workflows/deploy-tradein.yml | 36 +++++++++++--- .forgejo/workflows/deploy.yml | 67 +++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 8e1ba10a..640d12ff 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -291,8 +291,10 @@ jobs: ${{ env.IMAGE_BACKEND }}:${{ github.sha }} - name: Retry build & push tradein-backend без кеша (битый buildcache, #2841) - # cache-to тоже опущен: следующий успешный прогон С кешем перезапишет - # buildcache-тег целиком (mode=max) и самолечит порчу. + # cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2, + # issue #2): успешный ретрай перезаписывает битый buildcache-тег своими + # слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча + # оставалась навсегда, следующий прогон снова падал на том же cache-from. if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: @@ -303,10 +305,20 @@ jobs: APP_VERSION=${{ needs.changes.outputs.app_version }} BUILD_SHA=${{ needs.changes.outputs.build_sha }} BUILD_DATE=${{ needs.changes.outputs.build_date }} + cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # НЕ полагается на семантику steps.build.outcome/continue-on-error раннера — + # проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше). + # Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при + # упавшем build — этот шаг единственный это заметит: манифеста с этим SHA + # не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy + # ниже пропускается вместо накатки старого :latest на прод. + run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -414,8 +426,9 @@ jobs: ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} - name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841) - # См. tradein-backend: cache-to опущен намеренно (следующий успешный - # прогон с кешем перезапишет buildcache-тег целиком и самолечит порчу). + # См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to + # ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими + # слоями (mode=max), это и есть самолечение. if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: @@ -427,10 +440,15 @@ jobs: NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} + cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # См. tradein-backend выше — не полагается на steps.build.outcome раннера. + run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -518,17 +536,23 @@ jobs: ${{ env.IMAGE_BROWSER }}:${{ github.sha }} - name: Retry build & push tradein-browser без кеша (битый buildcache, #2841) - # См. tradein-backend: cache-to опущен намеренно (следующий успешный - # прогон с кешем перезапишет buildcache-тег целиком и самолечит порчу). + # См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to + # ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими + # слоями (mode=max), это и есть самолечение. if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: context: ./tradein-mvp/browser push: true + cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max tags: | ${{ env.IMAGE_BROWSER }}:latest ${{ env.IMAGE_BROWSER }}:${{ github.sha }} + - name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # См. tradein-backend выше — не полагается на steps.build.outcome раннера. + run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 6c40d79d..4485dd82 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -131,20 +131,35 @@ jobs: ${{ env.IMAGE_BACKEND }}:${{ github.sha }} - name: Retry build & push backend без кеша (битый buildcache, #2841) - # cache-to тоже опущен: следующий успешный прогон С кешем перезапишет - # buildcache-тег целиком (mode=max), это самолечит порчу. Если и retry - # упадёт — шаг красный БЕЗ continue-on-error, job честно FAILURE, и - # deploy ниже корректно пропускается (уже настоящая причина, не кеш). + # cache-from опущен (источник падения), а cache-to ОСТАВЛЕН: успешный + # ретрай пушит свежие слои в buildcache-тег и тем самым сам перезаписывает + # битый blob (mode=max — полная перезапись манифеста). Раньше cache-to был + # опущен и здесь тоже — но следующий обычный прогон опять получает cache-from + # на детерминированно битый тег и падает СНОВА: самолечения не было НИКОГДА + # (ревью #2841 R2, issue #2). Если и retry упадёт — шаг красный БЕЗ + # continue-on-error, job честно FAILURE, и deploy ниже корректно + # пропускается (уже настоящая причина, не кеш). if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: context: ./backend target: runner push: true + cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Проверить, что backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # НЕ полагается на семантику steps.build.outcome/continue-on-error раннера — + # проверяет РЕАЛЬНОЕ состояние registry напрямую через buildx (уже настроен + # выше). Если act_runner не заполняет outcome (не проверено живым прогоном, + # см. ревью), ретрай выше молча НЕ побежит при упавшем build, а этот шаг — + # единственный, кто это заметит: манифеста с этим SHA не будет → шаг падает + # БЕЗ continue-on-error → job честно FAILURE → deploy ниже пропускается + # вместо накатки старого :latest на прод. + run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -231,18 +246,27 @@ jobs: ${{ env.IMAGE_WORKER }}:${{ github.sha }} - name: Retry build & push worker без кеша (битый buildcache, #2841) - # См. backend: cache-to опущен намеренно (следующий успешный прогон с - # кешем перезапишет buildcache-тег целиком и самолечит порчу). + # См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН — + # успешный ретрай перезаписывает битый buildcache-тег своими слоями + # (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась + # навсегда — следующий прогон снова падал на том же cache-from. if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: context: ./backend target: runner-with-chromium push: true + cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max tags: | ${{ env.IMAGE_WORKER }}:latest ${{ env.IMAGE_WORKER }}:${{ github.sha }} + - name: Проверить, что worker:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # См. backend выше — не полагается на steps.build.outcome раннера, проверяет + # реальное состояние registry, чтобы молча пропущенный ретрай (если outcome + # не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом. + run: docker buildx imagetools inspect ${{ env.IMAGE_WORKER }}:${{ github.sha }} > /dev/null + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -331,8 +355,10 @@ jobs: ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} - name: Retry build & push frontend без кеша (битый buildcache, #2841) - # См. backend: cache-to опущен намеренно (следующий успешный прогон с - # кешем перезапишет buildcache-тег целиком и самолечит порчу). + # См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН — + # успешный ретрай перезаписывает битый buildcache-тег своими слоями + # (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась + # навсегда — следующий прогон снова падал на том же cache-from. if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: @@ -341,10 +367,17 @@ jobs: build-args: | NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }} NEXT_PUBLIC_ENVIRONMENT=production + cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Проверить, что frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # См. backend выше — не полагается на steps.build.outcome раннера, проверяет + # реальное состояние registry, чтобы молча пропущенный ретрай (если outcome + # не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом. + run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на @@ -679,11 +712,25 @@ jobs: docker image prune -af || true docker builder prune -af || true - # Health check + # Health check — деплой ВАЛИТСЯ, если backend не поднялся (см. #2214, + # уже сделано так в deploy-tradein.yml; ревью #2841 R2 issue #3). + # `curl ... && break` под set -e НЕ мог провалить скрипт: curl — не + # последняя команда &&-списка, а POSIX прямо освобождает от errexit + # все команды AND/OR-списка кроме последней. После 30 неуспешных + # попыток цикл завершался кодом последнего sleep (0) — скрипт тихо + # продолжался, деплой уходил success с мёртвым бэкендом. + healthy="" for i in $(seq 1 30); do - curl -fsS http://localhost:8000/health && break + if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then + healthy="yes"; break + fi sleep 1 done + if [ -z "$healthy" ]; then + echo "ERROR: backend не ответил на /health за 30s — деплой FAILED" + exit 1 + fi + echo "→ backend healthy на /health." # Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:` # молча (result=skipped), когда build падает (например, битый blob в From cb79c67bfc0986def771942405f730c127cf9cec Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:47:49 +0300 Subject: [PATCH 46/60] fix(tradein/deactivate): make TTL-cap multiplier configurable per source Review of 3a1e29a7 found CAP_MULT=2 is uniform across sources with wildly different ttl_days, so it produces a different ABSOLUTE ceiling per source: cian/yandex (ttl=30) -> 60d, avito (ttl=10) -> 20d, domklik (ttl=14) -> 28d. That breaks exactly where the crawl's revisit tail doesn't scale with ttl_days: avito's measured p99 revisit gap is 42.1d (_REVISIT_TAIL) -- above its own default cap of 20d -- so a legitimately slow-but-alive avito crawl cycle would get its floor cut below the very tail the floor exists to protect (the false-kill scenario #2659 was filed for). cian/yandex/ domklik aren't affected: their default ceilings (60/60/28) already sit comfortably above their own measured tails (26.6/43.0/3.1). Fix: cap_mult is now a function parameter (same pattern as revisit_floor_quantile/min_confirmations) with the module constant CAP_MULT as its default, wired through product_handlers via default_params["cap_mult"] so a schedule can override it without touching the shared default. Also closes the ttl_days<=0 edge case flagged in the same review: before the cap, max(ttl_days, floor) tolerated a misconfigured ttl_days<=0 as long as the floor was positive; with the cap, min(floor, ttl_days*cap_mult<=0) would silently defeat that protection and match nearly the whole active pool. ttl_days<=0 now raises ValueError before any SQL, same contract as the existing staleness_column whitelist check. Also verified (read-only, postgres-tradein) the review's core "no-op" claim: false. scrape_runs.counters for the 6 days since the revisit-floor went live (08-10..08-15) show the cap DID bind on 3 of 6 runs for avito (floor 52 vs cap 20) and 3 of 6 for yandex (floor 75 vs cap 60) -- the reviewer's "no source hits the cap" read a single-day trough right after a natural recovery, not the whole observation window. See PR discussion for the full counter history and refutation detail. Refs #2659 --- .../backend/app/services/product_handlers.py | 7 + .../app/tasks/deactivate_stale_avito.py | 62 +++++++-- .../tests/test_deactivate_stale_ttl_cap.py | 127 ++++++++++++++++++ 3 files changed, 183 insertions(+), 13 deletions(-) diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 3abf3360..c947e243 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -217,6 +217,7 @@ async def _job_deactivate_stale( ) -> None: from app.core.config import settings as _settings from app.tasks.deactivate_stale_avito import ( + CAP_MULT, DEFAULT_MIN_CONFIRMATIONS, DEFAULT_REVISIT_FLOOR_QUANTILE, deactivate_stale_listings, @@ -236,6 +237,11 @@ async def _job_deactivate_stale( revisit_floor_quantile: float = params.get( "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE ) + # Потолок эффективного TTL (см. CAP_MULT в deactivate_stale_avito.py) — множитель, + # а не голая константа: источник с непропорционально длинным хвостом переобхода + # относительно своего ttl_days переопределяет его через default_params (ключ + # "cap_mult"), не трогая дефолт для остальных источников. + cap_mult: float = params.get("cap_mult", CAP_MULT) loop = asyncio.get_event_loop() await loop.run_in_executor( @@ -249,6 +255,7 @@ async def _job_deactivate_stale( staleness_column=staleness_column, min_confirmations=min_confirmations, revisit_floor_quantile=revisit_floor_quantile, + cap_mult=cap_mult, ), ) diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index 52f82498..0b55cfee 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -213,6 +213,24 @@ _REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:s # отдельный гейт по здоровью (min_confirmations) выше в этой же функции, который # выключает деактивацию целиком, а не растягивает TTL до бесконечности. Калибровочная # ручка, не догма -- при новом замере можно пересмотреть, как и revisit_floor_quantile. +# +# ПОЧЕМУ MULT, А НЕ ФИКСИРОВАННОЕ ЧИСЛО СУТОК -- И ГДЕ ЭТА ФОРМА ЛОМАЕТСЯ. Множитель +# от ttl_days даёт разный АБСОЛЮТНЫЙ потолок на разных источниках: cian/yandex +# (ttl=30) -> 60 суток, avito (ttl=10) -> 20 суток, domklik (ttl=14) -> 28 суток. Это +# ломается ровно там, где абсолютный хвост переобхода источника НЕ пропорционален его +# ttl_days. Замер (_REVISIT_TAIL, 40 суток): avito p99 = 42.1 сут -- ВЫШЕ его же +# потолка 20. То есть для avito дефолтный CAP_MULT=2 может резать ttl ниже +# собственного хвоста обхода -- ровно тот false-kill, ради которого пол вообще +# заведён (см. комментарий выше). У cian/yandex (потолок 60) и domklik (потолок 28 +# при хвосте 3.1) такого разрыва нет -- множитель 2 для них калиброван верно. +# +# ПОЭТОМУ cap_mult -- параметр функции (как revisit_floor_quantile, min_confirmations), +# не голая константа: default = CAP_MULT для источников, где 2x достаточно, но +# расписание может переопределить через default_params (JSON-колонка scrape_schedules, +# ключ "cap_mult") для источника с непропорционально длинным хвостом -- см. миграцию +# для avito, поднимающую cap_mult до 6 (потолок 60 суток, тот же порядок, что у +# cian/yandex, и с запасом выше и статического p99=42.1, и живого прод-пика 52, +# замеренного 2026-08-10..12). CAP_MULT = 2 @@ -338,6 +356,7 @@ def deactivate_stale_listings( min_confirmations: int = 0, health_window_days: int = _HEALTH_WINDOW_DAYS, revisit_floor_quantile: float = 0.0, + cap_mult: float = CAP_MULT, ) -> dict[str, int]: """Пометить is_active=false объявления, чья свежесть старше ttl_days дней. @@ -360,10 +379,17 @@ def deactivate_stale_listings( health_window_days: окно подтверждений для гейта, суток. Дефолт 3. revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659). Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой; - эффективный TTL = min(max(ttl_days, этот пол), ttl_days * CAP_MULT) -- + эффективный TTL = min(max(ttl_days, этот пол), ttl_days * cap_mult) -- пол поднимает TTL, но не выше потолка. 0 -> пол выключен (так вызывают старые тесты и совместимая обёртка), рабочее значение — DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше. + cap_mult: множитель потолка эффективного TTL (см. комментарий у модульной + константы CAP_MULT). Дефолт -- сама CAP_MULT=2, но параметр, а НЕ голая + константа: источник с непропорционально длинным хвостом переобхода + относительно своего ttl_days (avito: p99=42.1 при ttl=10 -> дефолтный + потолок 20 режет ниже хвоста) может переопределить его через + default_params расписания (ключ "cap_mult"), не трогая остальные + источники. Итоговый потолок = ttl_days * cap_mult. Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots @@ -373,15 +399,22 @@ def deactivate_stale_listings( Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N, "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Если пол - упёрся в потолок CAP_MULT: дополнительно {"ttl_floor_capped": 1, + упёрся в потолок cap_mult: дополнительно {"ttl_floor_capped": 1, "ttl_days_floor_raw": N} -- N это то, во что пол поднял бы TTL БЕЗ потолка. Raises: - ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, - никакой интерполяции пользовательского ввода в запрос). + ValueError: если staleness_column не входит в whitelist, ИЛИ ttl_days <= 0 + (проверка ДО SQL, никакой интерполяции пользовательского ввода в запрос; + ttl_days<=0 в WHERE-условии last_seen_at < NOW() - INTERVAL 'N days' + матчит практически весь активный пул -- без явного guard'а потолок + (ttl_days * cap_mult <= 0) к тому же перебивал бы пол в формуле min(), + снимая защиту, которую max(ttl_days, floor) давал раньше). """ counters: dict[str, int] = {"deactivated": 0} try: + if ttl_days <= 0: + raise ValueError(f"ttl_days must be positive, got {ttl_days!r}") + # Whitelist-проверка ДО построения/выполнения SQL: только после неё имя колонки # интерполируется f-string'ом. Значения по-прежнему идут через param-binding. # Внутри try -> невалидная колонка финализирует run как failed (mark_failed), @@ -431,8 +464,9 @@ def deactivate_stale_listings( return counters # Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же - # срезу. Поднимает порог (max), но не выше потолка CAP_MULT * ttl_days (min) — - # см. комментарий у CAP_MULT про петлю с положительной обратной связью. + # срезу. Поднимает порог (max), но не выше потолка cap_mult * ttl_days (min) — + # см. комментарий у CAP_MULT про петлю с положительной обратной связью и про + # то, почему cap_mult -- параметр, а не голая константа. effective_ttl_days = ttl_days if revisit_floor_quantile > 0: floor_params: dict[str, Any] = { @@ -450,12 +484,14 @@ def deactivate_stale_listings( # Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего. if floor_days is not None: counters["revisit_floor_days"] = ceil(float(floor_days)) - # Пол поднимает TTL (max), потолок CAP_MULT его не пускает выше - # ttl_days * CAP_MULT (min) — без этого пол растёт без ограничения - # (см. комментарий у CAP_MULT). + # Пол поднимает TTL (max), потолок cap_mult его не пускает выше + # ttl_days * cap_mult (min) — без этого пол растёт без ограничения + # (см. комментарий у CAP_MULT). capped_ttl_days может быть float, + # если cap_mult переопределён нецелым значением из default_params — + # effective_ttl_days приводим к int (UPDATE ждёт целые сутки). raw_effective_ttl_days = max(ttl_days, counters["revisit_floor_days"]) - capped_ttl_days = ttl_days * CAP_MULT - effective_ttl_days = min(raw_effective_ttl_days, capped_ttl_days) + capped_ttl_days = ttl_days * cap_mult + effective_ttl_days = int(min(raw_effective_ttl_days, capped_ttl_days)) counters["ttl_days_effective"] = effective_ttl_days if raw_effective_ttl_days > capped_ttl_days: @@ -467,13 +503,13 @@ def deactivate_stale_listings( counters["ttl_days_floor_raw"] = raw_effective_ttl_days logger.warning( "deactivate_stale source=%s run_id=%d TTL пол упёрся в потолок " - "CAP_MULT=%d: пол поднял бы TTL до %d сут, потолок ограничивает " + "cap_mult=%s: пол поднял бы TTL до %d сут, потолок ограничивает " "заданные %d сут значением %d (квантиль %.3f, segments=%r) — " "растущий без ограничения пол это петля с положительной обратной " "связью, см. комментарий у CAP_MULT", listing_source, run_id, - CAP_MULT, + cap_mult, raw_effective_ttl_days, ttl_days, effective_ttl_days, diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py index 36f28a07..3e1762d5 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py @@ -16,6 +16,7 @@ ttl_days * CAP_MULT, независимо от того, насколько вы from __future__ import annotations import os +from pathlib import Path from typing import Any import pytest @@ -196,3 +197,129 @@ def test_avito_floor_above_ttl_but_under_cap_passes_through_uncapped( out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) assert out["ttl_days_effective"] == 18 assert "ttl_floor_capped" not in out + + +# ── cap_mult конфигурируем per-source (найдено ревью 2026-08-15) ──────────────── +# Дефолтный CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных источниках +# (cian/yandex 60 сут, avito 20 сут), а хвост переобхода не пропорционален +# ttl_days: avito p99=42.1 -- выше его же дефолтного потолка 20. cap_mult -- ручка +# для конкретно такого источника, без изменения дефолта для остальных. + + +def test_cap_mult_defaults_to_module_constant_when_not_overridden( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Без явного cap_mult поведение не меняется: потолок = ttl_days * CAP_MULT (2).""" + db = _FakeDB(floor_days=75.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 * task_mod.CAP_MULT + + +def test_cap_mult_override_raises_the_ceiling_for_a_long_tailed_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """avito p99=42.1: cap_mult=6 (потолок 60) больше не режет пол ниже хвоста обхода, + в отличие от дефолтного cap_mult=2 (потолок 20).""" + db = _FakeDB(floor_days=45.0) + out = _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + assert out["ttl_days_effective"] == 45 + assert "ttl_floor_capped" not in out + + +def test_cap_mult_override_still_caps_when_floor_exceeds_the_wider_ceiling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """cap_mult поднимает потолок, но не убирает его -- пол выше 60 всё равно срезается.""" + db = _FakeDB(floor_days=90.0) + out = _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + assert out["ttl_days_effective"] == 60 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 90 + + +def test_cap_mult_is_threaded_into_update_params(monkeypatch: pytest.MonkeyPatch) -> None: + """Капнутый по override'нутому потолку TTL реально уходит в UPDATE, не только считается.""" + db = _FakeDB(floor_days=90.0) + _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 60 + + +# ── ttl_days <= 0 (LOW из ревью 2026-08-15) ────────────────────────────────────── +# До потолка max(ttl_days, floor) прикрывал ttl_days<=0, если пол посчитан и +# положителен. С потолком min(raw, ttl_days * cap_mult) при ttl_days<=0 капнутый +# потолок тоже <= 0 и побеждает в min() -- защита пола пропадает молча. Явный guard +# ловит это ДО любого SQL, тем же путём, что и невалидный staleness_column. + + +def test_ttl_days_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=0, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=-5, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Тот же контракт, что и невалидный staleness_column: run помечается failed, + а не остаётся 'running'.""" + marked_failed: list[Any] = [] + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)), + ) + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 7, + listing_source="avito", + ttl_days=0, + ) + assert len(marked_failed) == 1 + assert marked_failed[0][0] == 7 + + +# ── проводка cap_mult в product_handlers ───────────────────────────────────────── + + +def test_handler_wires_cap_mult_from_schedule_params() -> None: + """Тот же приём, что test_handler_wires_revisit_floor_from_schedule_params: + читаем исходник файлом (product_handlers тянет scraper_kit, которого в юнит- + окружении может не быть) и проверяем именно проводку default_params -> вызов.""" + handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py" + src = handlers.read_text("utf-8") + job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0] + flat = " ".join(job.split()) + assert 'params.get("cap_mult", CAP_MULT)' in flat + assert "cap_mult=cap_mult" in job From e9ca744e85fd6a5ee7a620b02ad6491f866eb63e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:49:37 +0300 Subject: [PATCH 47/60] =?UTF-8?q?fix(tradein/scrapers):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=83=D1=82=D0=B0=D1=82=D1=8C=20rows=5Finserted/processe?= =?UTF-8?q?d=20=D1=81=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=20=D0=BA=D0=BB=D1=8E=D1=87=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью честного run-status нашло, что _RESULT_COUNTER_KEYS ловил не только целевой yandex_newbuilding_sweep, но и rosreestr_dkp_import (rows_inserted, 66 из 67 прод- прогонов = здоровый ноль догнавшего инкрементального импорта) и newbuilding_enrich (processed — счётчик попыток, ==limit даже при частичном провале). Первое завело бы практически непрерываемый ложный zero-стрик у здорового источника, второе маскировало бы реальные отказы под measured-N. Проверено по прод-БД (2026-08-15): "succeeded" пишут ТОЛЬКО yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich (65/90д) — ни разу rosreestr_dkp_import; у yandex_newbuilding_sweep succeeded численно совпадает с rows_inserted на всех 42/42 прогонах. Заменил "rows_inserted"+"processed" на "succeeded" в _RESULT_COUNTER_KEYS (app-копия и byte-эквивалентная kit-копия) — цель (b) исходной правки сохранена, ложный стрик у rosreestr_dkp_import снят, попутно newbuilding_enrich получает честное измерение вместо счётчика попыток. Также поправлены докстринги test_backfill_honest_status.py — два кейса (76%/72% отказов -> 'done') проверяют только выбор финализатора mark_backfill_finished (mark_done там замокан); реальный mark_done с honest-run-status переквалифицирует их в 'failed' через _failed_ratio_too_high — это не документировалось явно. --- .../backend/app/services/scrape_runs.py | 43 +++++---- .../tests/test_backfill_honest_status.py | 22 ++++- .../test_honest_run_status_failed_ratio.py | 95 ++++++++++++++++--- .../src/scraper_kit/orchestration/runs.py | 43 +++++---- 4 files changed, 155 insertions(+), 48 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index ccac68af..6fca052d 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -143,18 +143,28 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше # сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов # реально дали ноль. -# rows_inserted — yandex_newbuilding_sweep (единственный писатель ключа с таким -# именем на верхнем уровне counters): проверено на проде 26.07-10.08 — -# десять прогонов подряд, все 'done', processed=5 succeeded=0 -# rows_inserted=0 failed_resolve=4-5. Ни total_seen/lots_fetched/ -# unique_fetched у него нет, поэтому раньше _run_result_count всегда -# возвращал None ("не измерено") и стрик у сторожа не копился никогда -# (honest-run-status). -# processed — тот же sweep: сколько домов взял в работу. НАМЕРЕННО стоит ПОСЛЕ -# rows_inserted в кортеже — processed это счётчик ПОПЫТОК (аналог -# attempted), а не результата: у него ненулевое значение (=limit) даже -# когда rows_inserted=0, и если бы он читался первым, «5 обработано, -# 0 записано» замаскировалось бы под measured-5, а не measured-0. +# succeeded — yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich +# (65 прогонов/90д, единственные два писателя ключа на проде, +# проверено 2026-08-15). НЕ 'rows_inserted': тот ключ пишет ЕЩЁ и +# rosreestr_dkp_import (67 прогонов/90д) — у него rows_inserted=0 в +# 66 из 67 это ЗДОРОВЫЙ ответ догнавшего инкрементального импорта +# (rows_fetched=rows_skipped=96974, last_id не двигается неделями), +# а не отказ; если бы 'rows_inserted' попал в этот список, сторож +# зачитывал бы этот здоровый ноль как измеренный провал и копил бы +# практически непрерываемый стрик (rosreestr_dkp_import не +# прерывается другим статусом — импорт либо 'done', либо не бежал). +# НЕ 'processed' по той же причине с другой стороны: это счётчик +# ПОПЫТОК (у newbuilding_enrich processed==attempted==limit даже +# когда succeeded меньше — прод-факт 09.08: processed=25 succeeded=14, +# 44% отказов замаскировались бы под measured-25) — сторож нулевого +# результата на нём молчал бы ровно там, где должен сработать, а на +# будущем опустении очереди домов (cian_houses_pending) создал бы +# свой вечный ложный zero-стрик. 'succeeded' у yandex_newbuilding_sweep +# численно совпадает с 'rows_inserted' на всех 42/42 прод-прогонах — +# замена не теряет исходную цель (десять прогонов подряд 26.07-10.08, +# все 'done', succeeded=0 rows_inserted=0 failed_resolve=4-5 — раньше +# ни total_seen/lots_fetched/unique_fetched не было, и +# _run_result_count всегда возвращал None (honest-run-status)). # Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 # прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь # (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched @@ -166,8 +176,7 @@ _RESULT_COUNTER_KEYS = ( "total_seen", "lots_fetched", "unique_fetched", - "rows_inserted", - "processed", + "succeeded", ) @@ -368,14 +377,16 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: Приоритет ключей: - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched / - rows_inserted / processed) + succeeded) - new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted' (первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex, CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала new_count=0 у трёх подряд cian_full_load при реально сохранённых saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted' у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ, - которым yandex_newbuilding_sweep сообщает число upsert'ов. + которым yandex_newbuilding_sweep и rosreestr_dkp_import сообщают число upsert'ов; + здесь (для витринной колонки new_count) это безопасно — в отличие от + _RESULT_COUNTER_KEYS этот список не участвует в подсчёте zero-result-стрика. Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). diff --git a/tradein-mvp/backend/tests/test_backfill_honest_status.py b/tradein-mvp/backend/tests/test_backfill_honest_status.py index ad72db34..a885b74e 100644 --- a/tradein-mvp/backend/tests/test_backfill_honest_status.py +++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py @@ -5,7 +5,17 @@ 1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30 (494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done'). -Проверяем ровно ветвление mark_backfill_finished — БД замокана. +Проверяем ровно ветвление mark_backfill_finished — БД замокана (mark_done/mark_failed/ +mark_banned здесь fake-заглушки, регистрирующие ТОЛЬКО факт вызова). Это значит: кейсы +ниже с высокой долей отказов (attempted=50, failed=38 или 36 — 76%/72%), ожидающие +'done', проверяют лишь то, КАКОЙ финализатор ВЫБРАЛ mark_backfill_finished (#2674: +"обогатили хоть что-то — успех"), а НЕ то, что реально запишет в БД mark_done. С +honest-run-status (2026-08-15) mark_done САМ переквалифицирует такой прогон в 'failed' +через _failed_ratio_too_high (доля отказов >= 0.5) — реальный терминальный статус +для этих двух кейсов на проде теперь 'failed', не 'done'. Это намеренно проверяется +отдельно, БЕЗ мока mark_done, в tests/test_honest_run_status_failed_ratio.py +(test_prod_fact_avito_15_08_no_longer_done и соседние) — не читай эти два кейса как +"76%/72% отказов = 'done' в проде". """ from __future__ import annotations @@ -59,9 +69,15 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st ({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"), # Кандидатов не было — честная пустота, это успех. ({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"), - # Частичный прогон: обогатили хоть что-то → успех. + # Частичный прогон: обогатили хоть что-то → mark_backfill_finished ВЫБИРАЕТ + # mark_done как финализатор (#2674). 76% отказов (38 из 50) — здесь mark_done + # замокан, поэтому статус остаётся 'done'; в реальном mark_done с + # honest-run-status (2026-08-15) это переквалифицируется в 'failed' + # (_failed_ratio_too_high, доля >= 0.5) — см. докстринг модуля. ({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"), - # Блоки были, но прогон доработал и обогатил — не бан. + # Блоки были, но прогон доработал и обогатил — mark_backfill_finished выбирает + # НЕ 'banned'. 72% отказов (36 из 50) — та же оговорка: реальный mark_done + # переквалифицирует в 'failed', см. докстринг модуля выше. ({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"), # Блок оборвал прогон, хотя часть успели обогатить — работа не доделана. ({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"), diff --git a/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py index b8c03639..c32e4609 100644 --- a/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py +++ b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py @@ -12,8 +12,15 @@ processed=5, succeeded=0, rows_inserted=0, failed_resolve=4-5 — сторож нулевого результата (_alert_if_consecutive_zero_results) слеп, т.к. _RESULT_COUNTER_KEYS не знал ни одного ключа этого sweep'а (total_seen/lots_fetched/unique_fetched). - Фикс: _RESULT_COUNTER_KEYS дополнен rows_inserted/processed (в этом порядке — - rows_inserted это РЕЗУЛЬТАТ, processed это ПОПЫТКИ). + Фикс: _RESULT_COUNTER_KEYS дополнен 'succeeded'. Первая версия правки добавляла + голые 'rows_inserted'/'processed' — ревью нашло, что 'rows_inserted' пишет ЕЩЁ + rosreestr_dkp_import (66/67 прод-прогонов, здоровый ноль догнавшего импорта, а не + отказ) и завёл бы непрерываемый ложный zero-стрик, а 'processed' — счётчик + попыток (==limit даже при частичном провале у newbuilding_enrich) и маскирует + реальные отказы. 'succeeded' пишут только yandex_newbuilding_sweep и + newbuilding_enrich, численно совпадает с прежним 'rows_inserted' на всех + прод-прогонах sweep'а — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured + и test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже. (c) admin-витрина показывала new_count=0 у трёх подряд cian_full_load, хотя реально сохранено saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count', ни @@ -180,7 +187,8 @@ def test_honest_empty_sweep_unaffected_by_failed_ratio(name: str) -> None: def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None: """processed=5, succeeded=0, rows_inserted=0, failed_resolve=4 — раньше - _run_result_count возвращал None ("не измерено"); теперь — измеренный 0.""" + _run_result_count возвращал None ("не измерено"); теперь — измеренный 0 (через + 'succeeded', не 'rows_inserted' — см. ниже, почему ключ переигран ревью).""" counters = { "total": 309, "fetchable": 200, @@ -198,23 +206,61 @@ def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None: assert kit_runs._run_result_count(counters) == 0 -def test_rows_inserted_takes_priority_over_processed() -> None: - """rows_inserted (результат) читается ПЕРЕД processed (попытки) — иначе "5 - обработано, 0 записано" замаскировалось бы под measured-5.""" +def test_succeeded_is_the_measured_key_not_rows_inserted_or_processed() -> None: + """'succeeded' читается как результат; голые 'rows_inserted'/'processed' в + _RESULT_COUNTER_KEYS больше не участвуют (были в первой версии правки, снято + ревью — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured и + test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже).""" counters = {"processed": 5, "rows_inserted": 0} - assert app_runs._run_result_count(counters) == 0 + assert app_runs._run_result_count(counters) is None + assert kit_runs._run_result_count(counters) is None -def test_processed_is_fallback_when_rows_inserted_absent() -> None: - counters = {"processed": 3} - assert app_runs._run_result_count(counters) == 3 +def test_rosreestr_dkp_import_healthy_zero_stays_unmeasured() -> None: + """Прод-факт rosreestr_dkp_import (2026-08-15, 66 из 67 прогонов за 90д): инкрементальный + импорт догнал источник — rows_fetched==rows_skipped, rows_inserted=0. Это ЗДОРОВЫЙ + ответ (нечего вставлять), а не отказ; словарь не содержит 'succeeded' вовсе. + + Первая версия правки добавляла голый 'rows_inserted' в _RESULT_COUNTER_KEYS — тогда + этот прод-факт читался бы как "измеренный провал" и копил бы практически + непрерываемый zero-стрик (rosreestr_dkp_import не прерывается другим статусом: + он либо 'done' с этим же нулём, либо не бежал). Ревью поймало это до деплоя — + правильный ответ: "не измерено" (None), стрик не копится.""" + counters = { + "last_id": 6829903, + "batches_done": 49, + "rows_errored": 0, + "rows_fetched": 96974, + "rows_skipped": 96974, + "rows_updated": 0, + "rows_inserted": 0, + } + assert app_runs._run_result_count(counters) is None + assert kit_runs._run_result_count(counters) is None + + +def test_newbuilding_enrich_partial_failure_not_masked_by_processed() -> None: + """Прод-факт newbuilding_enrich (09.08): processed=25 (счётчик ПОПЫТОК, ==limit), + succeeded=14 — 44% отказов. Если бы сторож читал 'processed' как результат, партиальный + провал замаскировался бы под measured-25 (сторож нулевого результата промолчал бы + ровно там, где должен был сработать при полном провале). 'succeeded' даёт честные 14.""" + counters = { + "failed": 11, + "enriched": 14, + "attempted": 25, + "processed": 25, + "succeeded": 14, + "failed_fetch": 11, + } + assert app_runs._run_result_count(counters) == 14 + assert kit_runs._run_result_count(counters) == 14 @pytest.mark.parametrize("name", list(_MODULES)) def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) -> None: - """(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с - rows_inserted=0 -> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал - результат "не измеренным" и молчал бы вечно (см. #2703 в docstring модуля).""" + """(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с succeeded=0 + -> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал результат "не + измеренным" и молчал бы вечно (см. #2703 в docstring модуля).""" mod = _MODULES[name] row = MagicMock() row.status = "done" @@ -228,6 +274,29 @@ def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) mock_sentry.capture_message.assert_called_once() +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_result_watchdog_silent_on_rosreestr_dkp_import_streak(name: str) -> None: + """Негативный аналог теста выше: та же лестница из 3 подряд 'done', но словарь + rosreestr_dkp_import (нет 'succeeded') -> сторож не считает результат измеренным + и НЕ шлёт алерт — регрессионный тест на замечание ревью (HIGH #1).""" + mod = _MODULES[name] + row = MagicMock() + row.status = "done" + row.counters = { + "last_id": 6829903, + "rows_fetched": 96974, + "rows_skipped": 96974, + "rows_inserted": 0, + } + db = MagicMock() + result = MagicMock() + result.fetchall.return_value = [row, row, row] + db.execute.return_value = result + with patch.object(mod, "sentry_sdk") as mock_sentry: + mod._alert_if_consecutive_zero_results(db, "rosreestr_dkp_import") + mock_sentry.capture_message.assert_not_called() + + # ── (c) _column_counts: прод-факт cian_full_load new_count=0 при saved_inserted>0 ─── diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 279ea928..725b1613 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -138,18 +138,28 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше # сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов # реально дали ноль. -# rows_inserted — yandex_newbuilding_sweep (единственный писатель ключа с таким -# именем на верхнем уровне counters): проверено на проде 26.07-10.08 — -# десять прогонов подряд, все 'done', processed=5 succeeded=0 -# rows_inserted=0 failed_resolve=4-5. Ни total_seen/lots_fetched/ -# unique_fetched у него нет, поэтому раньше _run_result_count всегда -# возвращал None ("не измерено") и стрик у сторожа не копился никогда -# (honest-run-status). -# processed — тот же sweep: сколько домов взял в работу. НАМЕРЕННО стоит ПОСЛЕ -# rows_inserted в кортеже — processed это счётчик ПОПЫТОК (аналог -# attempted), а не результата: у него ненулевое значение (=limit) даже -# когда rows_inserted=0, и если бы он читался первым, «5 обработано, -# 0 записано» замаскировалось бы под measured-5, а не measured-0. +# succeeded — yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich +# (65 прогонов/90д, единственные два писателя ключа на проде, +# проверено 2026-08-15). НЕ 'rows_inserted': тот ключ пишет ЕЩЁ и +# rosreestr_dkp_import (67 прогонов/90д) — у него rows_inserted=0 в +# 66 из 67 это ЗДОРОВЫЙ ответ догнавшего инкрементального импорта +# (rows_fetched=rows_skipped=96974, last_id не двигается неделями), +# а не отказ; если бы 'rows_inserted' попал в этот список, сторож +# зачитывал бы этот здоровый ноль как измеренный провал и копил бы +# практически непрерываемый стрик (rosreestr_dkp_import не +# прерывается другим статусом — импорт либо 'done', либо не бежал). +# НЕ 'processed' по той же причине с другой стороны: это счётчик +# ПОПЫТОК (у newbuilding_enrich processed==attempted==limit даже +# когда succeeded меньше — прод-факт 09.08: processed=25 succeeded=14, +# 44% отказов замаскировались бы под measured-25) — сторож нулевого +# результата на нём молчал бы ровно там, где должен сработать, а на +# будущем опустении очереди домов (cian_houses_pending) создал бы +# свой вечный ложный zero-стрик. 'succeeded' у yandex_newbuilding_sweep +# численно совпадает с 'rows_inserted' на всех 42/42 прод-прогонах — +# замена не теряет исходную цель (десять прогонов подряд 26.07-10.08, +# все 'done', succeeded=0 rows_inserted=0 failed_resolve=4-5 — раньше +# ни total_seen/lots_fetched/unique_fetched не было, и +# _run_result_count всегда возвращал None (honest-run-status)). # Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 # прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь # (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched @@ -161,8 +171,7 @@ _RESULT_COUNTER_KEYS = ( "total_seen", "lots_fetched", "unique_fetched", - "rows_inserted", - "processed", + "succeeded", ) @@ -368,14 +377,16 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: Приоритет ключей: - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched / - rows_inserted / processed) + succeeded) - new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted' (первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex, CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала new_count=0 у трёх подряд cian_full_load при реально сохранённых saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted' у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ, - которым yandex_newbuilding_sweep сообщает число upsert'ов. + которым yandex_newbuilding_sweep и rosreestr_dkp_import сообщают число upsert'ов; + здесь (для витринной колонки new_count) это безопасно — в отличие от + _RESULT_COUNTER_KEYS этот список не участвует в подсчёте zero-result-стрика. Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). From fa84705ec71646b15105eb71da90545692f5140c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:50:17 +0300 Subject: [PATCH 48/60] fix(tradein/geocoder): stop apt number leaking into house + houses bbox/sibling guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 on #2626 (local houses fallback) found two HIGH-severity bugs verified live against prod data: 1. _extract_local_house_token took the LAST digit-like token in the raw address, so "...Педагогическая, д 15, кв 11" resolved house=11 (apartment number) instead of 15 -- confidently returning a stranger's building with confidence='exact', written to geocode_cache. Fixed by stripping the apartment/office/floor/entrance tail (кв/оф/пом/подъезд/этаж -- NOT корп/к, which is part of the house number) before extracting the token. Fixes the exact prod case from the review plus the corpus+apartment combo ("д 26 к 1, кв 41" -> 26к1, not 41). 2. houses is not an EKB-only table (21% of rows with coords are outside the metro, some as far as another city) -- "улица Маяковского, 7" in houses resolves to Серов, not Екатеринбург, and use_local_ekb only gates the user's query text, not the source row. Added an is_within_ekb_bbox_wide check on every candidate row before it can become a match. Also addressed two MEDIUM findings from the same review: 3. The "<номер> -> <номер>к1" corpus guess only checked uniqueness among к1-labelled rows, so real multi-building addresses (Онуфриева 24: к1/к2/к3, 250-400m apart) resolved confidently to к1 anyway. Guess is now skipped when any other corpus/slash variant of the same base number exists among the street's candidates. 4. Houses-fallback results are no longer cached in geocode_cache -- the source (scraped listings) is less reliable than geoportal/cadastral/ Nominatim, and the lookup is cheap/local, so caching only extended the lifetime of a possible bad match. Side benefit: address_refined now survives every repeat request of the same raw address, not just the first. Also added ORDER BY address, id to the underlying query so the coordinate dedup picks a deterministic row (LOW finding #5). 14 new/updated tests in test_geocoder_local_houses_fallback.py cover all five findings against real prod address/houses-row fixtures. Full geocoder + dadata + estimator/pdf regression suite (402 tests) green. --- tradein-mvp/backend/app/services/geocoder.py | 132 ++++++++++++--- .../test_geocoder_local_houses_fallback.py | 154 +++++++++++++++++- 2 files changed, 256 insertions(+), 30 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 8d798476..420728dd 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -51,11 +51,14 @@ class GeocodeResult: # корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по # нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён # автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`. - # Известный предел: `geocode_cache` НЕ хранит этот флаг (схему не трогаем) — - # на повторный запрос ТОГО ЖЕ сырого адреса из кэша координаты корректные, но - # `address_refined` вернётся `False` (та же судьба у `city_ambiguous` при - # cache-hit — см. `_geocode_resolve`, восстанавливается `replace()` из - # текущего вызова, а не из кэша). + # Houses-фолбэк НЕ пишет свой результат в `geocode_cache` (менее надёжный + # источник координат, чем geoportal/cadastral/Nominatim — #2626 review R2 #4), + # поэтому этот сигнал переживает КАЖДЫЙ повторный запрос того же сырого + # адреса. `geocode_cache` вообще не хранит этот флаг (схему не трогаем) — + # если бы houses-хит когда-нибудь попал в кэш, на cache-hit `address_refined` + # вернулся бы `False` (та же судьба у `city_ambiguous` при cache-hit — см. + # `_geocode_resolve`, восстанавливается `replace()` из текущего вызова, а не + # из кэша). address_refined: bool = False @@ -1361,13 +1364,33 @@ def _norm_local_house(raw: str) -> str: return s +# Хвостовой мусор ПОСЛЕ номера дома — квартира/офис/помещение/подъезд/этаж. +# НЕ включает «корп/корпус/к» (в отличие от `_RE_APT_TAIL` выше) — корпус тут +# ЧАСТЬ номера дома, который должен остаться видимым для `_LOCAL_HOUSE_TOKEN_RE` +# («49к1», «26 к 1» — корпус нельзя терять). Без этой зачистки +# `_extract_local_house_token` (берёт ПОСЛЕДНЕЕ число в строке) находит номер +# квартиры/этажа вместо дома — прод-баг #2626 review R2 #1: «...Педагогическая, +# д 15, кв 11» отдавал дом «11» (координаты ЧУЖОГО здания) вместо «15». +_RE_LOCAL_APT_TAIL = re.compile( + r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|лит|подъезд|этаж)\.?\s*\d.*$", + re.IGNORECASE, +) + + def _extract_local_house_token(address: str) -> str | None: """Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов, которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у `_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение — номер дома в русском адресе почти всегда в хвосте строки. None, если цифр нет вовсе. + + Квартирный/этажный/подъездный хвост зачищается ДО поиска номера + (`_RE_LOCAL_APT_TAIL`) — иначе «последнее число в строке» это номер + квартиры/этажа, а не дома (см. докстринг у `_RE_LOCAL_APT_TAIL`). """ s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.") + if not s: + return None + s = _RE_LOCAL_APT_TAIL.sub(" ", s).strip(" ,.") if not s: return None matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s)) @@ -1431,25 +1454,53 @@ def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool: return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm) +# «24к1» → «24» (базовый номер варианта с корпусом/слэшем); «44» (голый номер, +# без суффикса) → None. Используется ТОЛЬКО для sibling-guard (см. ниже) — +# отличить «этот дом однозначно к1» от «этого дома несколько корпусов, а у +# нас в вводе просто нет данных, какой именно». +_LOCAL_HOUSE_VARIANT_BASE_RE = re.compile(r"^(\d+)(?:к\d+|/\d+)$") + + def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None: """Последний локальный тир `geocode()` (#2626) — fallback на `houses` (скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW). Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата. - Два независимых допущения, оба defensive (при неоднозначности — None, не гадаем): + Допущения, все defensive (при неоднозначности — None, не гадаем): 1. Улица матчится «по хвосту» (`_street_tail_matches`) — ловит расхождение разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в houses («Начдива Онуфриева»). - 2. Номер дома — сперва точное совпадение; нет — пробуем `<номер>к1` (частый - случай: пользователь ввёл «49», у дома есть только корпус «49к1»). ЛЮБОЙ - шаг, где кандидатов больше одного (после дедупа по координатам — разные - source-строки ОДНОГО дома не в счёт), возвращает None — угадывать нельзя. + 2. Координаты строки-кандидата обязаны лежать в широком ЕКБ-bbox + (`is_within_ekb_bbox_wide`) — `houses` НЕ ЕКБ-only реестр (в отличие от + geoportal/cad_buildings): 21% строк с координатами лежат вне области ЕКБ, + местами вплоть до другого региона (#2626 review R2 #2 — прод-пример + «улица Маяковского, 7» в houses это Серов, а не запрошенный + Екатеринбург). `use_local_ekb` в `geocode()` гейтит только ЗАПРОС + пользователя, не страхует от грязной строки-источника. + 3. Номер дома — сперва точное совпадение; нет — пробуем `<номер>к1` (частый + случай: пользователь ввёл «49», у дома есть только корпус «49к1»), но + ТОЛЬКО если среди кандидатов улицы НЕТ других корпусов/дробей этого же + номера («24к2», «24/2» и т.п.) — иначе «к1» такая же угадайка, как и + любой другой корпус, и реальные дома могут быть в 250-400м друг от друга + (#2626 review R2 #3, прод-пример «Начдива Онуфриева, 24»: 24к1/24к2/24к3 + — три разных здания). + 4. ЛЮБОЙ шаг, где кандидатов больше одного (после дедупа по округлённым + координатам — разные source-строки ОДНОГО дома не в счёт), возвращает + None — угадывать нельзя. SQL — дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на - `houses.address`, но тир последний и редкий — не на каждый запрос), вся - точная логика (суффикс улицы + равенство номера) — в Python, что и делает - её юнит-тестируемой без реальной БД (см. `test_geocoder_local_houses_fallback.py`). + `houses.address`, но тир последний и редкий — не на каждый запрос) с + детерминированным ORDER BY (дедуп по координатам иначе непредсказуемо + выбирал бы, какая из двух ~идентичных source-строк станет ответом — + #2626 review R2 #5); вся точная логика (суффикс улицы, bbox, равенство + номера) — в Python, что и делает её юнит-тестируемой без реальной БД + (см. `test_geocoder_local_houses_fallback.py`). + + Результат этого тира НЕ кэшируется в `geocode_cache` вызывающей стороной + (см. `geocode()`) — `houses`-координаты из скрейпленных объявлений менее + надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый и локальный + (#2626 review R2 #4). """ query_street_norm = _clean_local_house_street(street) if not query_street_norm: @@ -1466,6 +1517,7 @@ def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggesti FROM houses WHERE address ILIKE CAST('%' || :w || '%' AS text) AND lat IS NOT NULL AND lon IS NOT NULL + ORDER BY address, id """), {"w": last_word}, ).fetchall() @@ -1478,23 +1530,32 @@ def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggesti ) return None + # Street-tail + bbox фильтр — один проход, дальше переиспользуется и для + # точного совпадения, и для corpus-1 догадки, и для sibling-guard. + street_rows: list[tuple[str, float, float, str]] = [] # (house_norm, lat, lon, addr) + for r in rows: + parsed = _row_local_house(str(r.address or "")) + if parsed is None: + continue + row_street_norm, row_house_norm = parsed + if not _street_tail_matches(row_street_norm, query_street_norm): + continue + lat, lon = float(r.lat), float(r.lon) + if not is_within_ekb_bbox_wide(lat, lon): + continue + street_rows.append((row_house_norm, lat, lon, str(r.address))) + def _candidates(house_norm: str) -> list[tuple[str, float, float]]: out: list[tuple[str, float, float]] = [] seen_coords: set[tuple[float, float]] = set() - for r in rows: - parsed = _row_local_house(str(r.address or "")) - if parsed is None: - continue - row_street_norm, row_house_norm = parsed + for row_house_norm, lat, lon, addr in street_rows: if row_house_norm != house_norm: continue - if not _street_tail_matches(row_street_norm, query_street_norm): - continue - coord_key = (round(float(r.lat), 4), round(float(r.lon), 4)) # ~11m — дедуп источников + coord_key = (round(lat, 4), round(lon, 4)) # ~11m — дедуп источников if coord_key in seen_coords: continue seen_coords.add(coord_key) - out.append((str(r.address), float(r.lat), float(r.lon))) + out.append((addr, lat, lon)) return out exact = _candidates(query_house_norm) @@ -1514,6 +1575,21 @@ def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggesti # если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»). if query_house_norm.isdigit(): corpus1 = f"{query_house_norm}к1" + siblings = { + row_house_norm + for row_house_norm, _lat, _lon, _addr in street_rows + if row_house_norm != corpus1 + and (m := _LOCAL_HOUSE_VARIANT_BASE_RE.match(row_house_norm)) is not None + and m.group(1) == query_house_norm + } + if siblings: + logger.info( + "local houses fallback: корпус-1 %r неоднозначен — есть другие " + "корпуса/дроби %s — skip", + corpus1, + sorted(siblings), + ) + return None guessed = _candidates(corpus1) if len(guessed) == 1: addr, lat, lon = guessed[0] @@ -1810,7 +1886,10 @@ async def _geocode_resolve( # резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий # в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у # geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск - # коллизии улица+дом с другим городом региона, что и мотивировал #2582). + # коллизии улица+дом с другим городом региона, что и мотивировал #2582); + # координаты строки-кандидата ДОПОЛНИТЕЛЬНО проверяются bbox-ом внутри + # `_local_houses_match` (гейт здесь фильтрует только запрос пользователя, + # не грязь в самой таблице — #2626 review R2 #2). if use_local_ekb and parsed is not None: local_street, _parsed_house = parsed local_house = _extract_local_house_token(address) or _parsed_house @@ -1825,7 +1904,12 @@ async def _geocode_resolve( city_ambiguous=city_ambiguous, address_refined=True, ) - await asyncio.to_thread(_cache_put, db, addr_norm, result) + # НЕ кэшируем: houses-координаты (скрейпленные листинги) менее + # надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый + # и локальный — кэш только продлевал бы жизнь возможной ошибке + # источника (#2626 review R2 #4). Побочный эффект: `address_refined` + # переживает КАЖДЫЙ повторный запрос этого сырого адреса, а не + # только первый (было известным пределом до этого фикса). logger.info( "geocode local houses fallback: %s → (%.5f, %.5f) [%s]", addr_norm, diff --git a/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py b/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py index fef919a4..d28941c2 100644 --- a/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py +++ b/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py @@ -83,6 +83,32 @@ def test_norm_local_house(raw: str, expected: str) -> None: ("Крестинского 49к1", "49к1"), ("8 Марта 204", "204"), # digit-leading street name doesn't confuse it ("Малышева 30", "30"), + # #2626 review R2 #1 — прод-баг: квартира подменяла дом («д 15, кв 11» + # → дом «11», чужое здание). Реальные строки из trade_in_estimates: + ( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 11", + "15", + ), + ( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 48", + "15", + ), + # корпус ПЕРЕД квартирой — «26 к 1» обязан остаться частью номера дома, + # «кв 41» — уйти: + ( + "620149, Свердловская обл, г Екатеринбург, Ленинский р-н, " + "ул Начдива Онуфриева, д 26 к 1, кв 41", + "26к1", + ), + # подъезд/этаж — тот же класс бага, что и квартира (последнее число в + # строке — не дом): + ( + "Россия, Свердловская область, Екатеринбург, Трамвайный переулок, " + "2к2, подъезд 1, этаж 25, кв. 205", + "2к2", + ), ], ) def test_extract_local_house_token(address: str, expected: str) -> None: @@ -186,7 +212,30 @@ def test_local_houses_match_exact_house_number() -> None: def test_local_houses_match_street_tail_and_corpus1_guess() -> None: - """«Онуфриева, 24» (без «Начдива», без корпуса) → единственный «24к1» реестра.""" + """«Онуфриева, 24» (без «Начдива», без корпуса), реестр — ЕДИНСТВЕННЫЙ + корпус «24к1» → уверенная догадка (нет sibling-корпусов — не угадайка).""" + db = _db_with_rows( + [ + _make_row( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + 56.802928, + 60.551696, + ), + ] + ) + + hit = _local_houses_match(db, "онуфриева", "24") + + assert hit is not None + assert hit.lat == pytest.approx(56.802928) + assert hit.lon == pytest.approx(60.551696) + + +def test_local_houses_match_corpus1_guess_skipped_when_sibling_corpus_exists() -> None: + """#2626 review R2 #3, прод-данные: «Начдива Онуфриева, 24» реально ТРИ + разных здания (24к1/24к2/24к3, 250-400м друг от друга). Догадка «→24к1» + не угадывает конкретное здание среди known-siblings — честный None, не + «уверенный» результат с confidence='exact' на случайно выбранном доме.""" db = _db_with_rows( [ _make_row( @@ -199,11 +248,19 @@ def test_local_houses_match_street_tail_and_corpus1_guess() -> None: ] ) - hit = _local_houses_match(db, "онуфриева", "24") + assert _local_houses_match(db, "онуфриева", "24") is None - assert hit is not None - assert hit.lat == pytest.approx(56.802928) - assert hit.lon == pytest.approx(60.551696) + +def test_local_houses_match_corpus1_guess_skipped_when_slash_sibling_exists() -> None: + """Sibling-guard ловит не только «кN», но и «/N» вариант того же номера.""" + db = _db_with_rows( + [ + _make_row("улица X, 24к1", 56.80, 60.60), + _make_row("улица X, 24/2", 56.81, 60.61), + ] + ) + + assert _local_houses_match(db, "x", "24") is None def test_local_houses_match_no_corpus1_candidate_returns_none() -> None: @@ -278,6 +335,50 @@ def test_local_houses_match_returns_none_on_db_error() -> None: assert _local_houses_match(db, "онуфриева", "24") is None +# ── bbox guard: `houses` is NOT EKB-only (#2626 review R2 #2) ─────────────── + + +def test_local_houses_match_rejects_row_outside_ekb_bbox() -> None: + """Прод-кейс: «улица Маяковского, 7» в `houses` — это Серов (56.6/60.66 — + ~310км от ЕКБ), не Екатеринбург. `use_local_ekb` в `geocode()` гейтит только + ЗАПРОС пользователя, не координаты строки-источника — bbox-фильтр внутри + `_local_houses_match` обязан отбросить такую строку, а не вернуть её как + confidence='exact' совпадение чужого города.""" + db = _db_with_rows( + [_make_row("улица Маяковского, 7", 59.652903, 60.659674)], # Серов, не ЕКБ + ) + + assert _local_houses_match(db, "маяковского", "7") is None + + +def test_local_houses_match_accepts_row_inside_ekb_bbox_wide() -> None: + """Контроль: легитимная ЕКБ-строка (в т.ч. приграничье, в WIDE, не в TIGHT) + по-прежнему проходит — bbox-фильтр не режет реальные ЕКБ-дома.""" + db = _db_with_rows( + [_make_row("Екатеринбург, улица Маяковского, 8", 56.862701, 60.620274)], + ) + + hit = _local_houses_match(db, "маяковского", "8") + + assert hit is not None + assert hit.lat == pytest.approx(56.862701) + + +# ── deterministic ORDER BY (#2626 review R2 #5) ────────────────────────────── + + +def test_local_houses_match_query_has_deterministic_order_by() -> None: + """Без ORDER BY дедуп по округлённым координатам оставлял бы ПЕРВУЮ строку + в порядке сканирования — недетерминированно между вызовами. SQL обязан + сортировать явно.""" + db = _db_with_rows([]) + + _local_houses_match(db, "x", "1") + + sql_text = str(db.execute.call_args[0][0]) + assert "ORDER BY" in sql_text.upper() + + # ── geocode() wiring — last-resort tier, sets address_refined ─────────────── @@ -298,7 +399,7 @@ async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None patch("app.services.geocoder._geoportal_house_match", return_value=None), patch("app.services.geocoder._cadastral_house_match", return_value=None), patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), - patch("app.services.geocoder._cache_put"), + patch("app.services.geocoder._cache_put") as mock_cache_put, patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, @@ -316,6 +417,9 @@ async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None assert result.confidence == "exact" assert result.address_refined is True mock_local.assert_called_once() + # #2626 review R2 #4 — houses-фолбэк дешёвый и менее надёжный источник + # координат, чем geoportal/cadastral/Nominatim — свой результат не кэширует. + mock_cache_put.assert_not_called() async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None: @@ -366,3 +470,41 @@ async def test_geocode_returns_none_when_local_houses_also_misses() -> None: assert result is None mock_local.assert_called_once() + + +async def test_geocode_local_houses_apartment_number_does_not_leak_into_house() -> None: + """End-to-end regression, #2626 review R2 #1: реальный прод-адрес с хвостом + «кв 11» должен резолвиться в дом 15 (`Педагогическая ул.,15`), а НЕ в дом 11 + (`Педагогическая ул.,11` — чужое здание) — `_local_houses_match` не + замокан, проверяем полную цепочку `geocode()` → `_extract_local_house_token` + → SQL-lookup.""" + db = _db_with_rows( + [ + _make_row("Педагогическая ул.,11", 56.835387, 60.654104), + _make_row("Педагогическая ул.,15", 56.835284, 60.655829), + ] + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=None), + patch("app.services.geocoder._cadastral_house_match", return_value=None), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put") as mock_cache_put, + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=None, + ), + ): + result = await geocode( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 11", + db, + ) + + assert result is not None + assert result.lat == pytest.approx(56.835284) + assert result.lon == pytest.approx(60.655829) + assert result.address_refined is True + mock_cache_put.assert_not_called() From cb0f42d1b11e50453afdcdc0e55ad178951bd3d2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 19:22:22 +0300 Subject: [PATCH 49/60] =?UTF-8?q?fix(health):=20=D0=BD=D0=B5=20=D1=82?= =?UTF-8?q?=D0=B0=D1=89=D0=B8=D1=82=D1=8C=20HEAD-=D0=BF=D1=80=D0=BE=D0=B1?= =?UTF-8?q?=D1=83=20=D0=B2=20OpenAPI-=D1=81=D1=85=D0=B5=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Джоба openapi-codegen-check покраснела на этой ветке: она дампит app.openapi(), регенерирует frontend/src/types/api-types.ts и падает на расхождении. Добавленный HEAD /health попал в схему и потребовал правки сгенерированного файла. Регенерировать типы ради маршрута, который фронт никогда не вызывает, — лишний шум в generated-коде. HEAD-проба это инфраструктура для uptime-монитора, а не часть контракта, по которому фронт строит типы, поэтому include_in_schema=False здесь и по смыслу верно, а не только удобно. Флаг ставим в обоих бэкендах симметрично: у trade-in codegen-джобы пока нет, но расхождение схем между двумя бэкендами потом само станет источником вопросов. --- backend/app/main.py | 7 ++++++- tradein-mvp/backend/app/main.py | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index c779e335..5f6507ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -521,6 +521,11 @@ async def health() -> dict[str, str]: # дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload- # заголовки (Content-Length) для HEAD, требует совпадения только заголовков # представления (Content-Type). -@app.head("/health") +# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть +# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в +# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации +# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута, +# который фронт никогда не вызывает. +@app.head("/health", include_in_schema=False) async def health_head() -> Response: return Response(status_code=200, media_type="application/json") diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 8a48c7c3..d9b7aaff 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -222,7 +222,10 @@ def health() -> dict[str, str]: # сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload) # — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для # HEAD, требует совпадения только заголовков представления (Content-Type). -@app.head("/health") +# include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это +# инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим +# симметрично, чтобы схема двух бэкендов не разъезжалась. +@app.head("/health", include_in_schema=False) def health_head() -> Response: return Response(status_code=200, media_type="application/json") From 8f954fb98c19bd7c33784f75a840f61e9d014a31 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 19:24:32 +0300 Subject: [PATCH 50/60] =?UTF-8?q?feat(mera/b2c):=20=D0=BB=D1=8D=D0=BD?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B3=20v3=20=D0=BF=D0=BE=20=D0=BC=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D1=83=20=E2=80=94=20=D0=BF=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=BD=D0=B0=20/mera-public/v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дизайн-макет владельца (15.08.2026). Собрано рядом с живым лэндингом, а не вместо него: /mera-public открыт на meraocenka.ru, менять его посреди согласования дизайна нельзя. - b2c-tokens.ts — палитра макета отдельно от v2/tokens.ts. Красить теми же токенами B2B-экран нельзя, это другой продукт; hex-литералы остаются в одном файле. - marketing-v3.ts — числа из макета в ОДНОМ месте, с таблицей расхождений с нашими замерами и гейтом: заменить вычисляемыми до снятия noindex. - _components/v3/ — шапка с селектором города, подвал, липкая CTA, герой с карточкой проверки, три шага, «Точность», «Цена ошибки». - layout.tsx больше не рендерит шапку/подвал: вложенный layout в Next ДОБАВЛЯЕТСЯ к родительскому, поэтому v3 со своим хромом получал бы две шапки. Теперь хром выбирает страница — v1 берёт SiteHeader/SiteFooter, v3 свой. Отступления от макета (все задокументированы в коде): - реквизиты и документы берутся из LEGAL_ENTITY, а не из маски «ООО «МЕРА» · ИНН 66700XXXXX» в макете; - в подвал добавлены Политика возврата и почта поддержки, которых в макете нет, — обязательные элементы платного контура; - бейджи МИР/СБП/VISA не рендерятся: приём платежей не подключён; - форма не выдаёт результат — анонимного расчёта нет, а одинаковые «14 объявлений» на любой адрес это обман конкретного человека, а не продающая формулировка. Ветка результата свёрстана и ждёт /coverage; - замыленная рублёвая цифра не перенесена: в макете она лежит в разметке текстом под filter:blur и достаётся из исходника; - акцент #16A9BC не используется как цвет текста (2,6:1 на фоне страницы) — для текста затемнённый #0B6B79, 5,8:1; - города и объёмы в селекторе — из нашего реестра, а не 42 города РФ. Секции макета, требующие отсутствующих данных и контуров (лента прогноз→факт, игра, продажа под ключ, статьи, модалка, оплата), не верстались — заведены задачами #2894-#2904. Проверено: tsc --noEmit, next lint, isolation guard (31 файл), next build — /mera-public/v3 пререндерится статикой. --- .../mera-public/_components/v3/AccuracyV3.tsx | 154 ++ .../mera-public/_components/v3/CityPicker.tsx | 140 ++ .../_components/v3/CostOfErrorV3.tsx | 52 + .../mera-public/_components/v3/FooterV3.tsx | 145 ++ .../_components/v3/FreeCheckCard.tsx | 303 ++++ .../_components/v3/FreeResultV3.tsx | 100 ++ .../mera-public/_components/v3/HeaderV3.tsx | 76 + .../app/mera-public/_components/v3/HeroV3.tsx | 107 ++ .../mera-public/_components/v3/StepsV3.tsx | 100 ++ .../_components/v3/StickyCtaV3.tsx | 38 + .../src/app/mera-public/b2c-tokens.ts | 69 + .../src/app/mera-public/landing-v3.module.css | 1357 +++++++++++++++++ .../frontend/src/app/mera-public/layout.tsx | 14 +- .../src/app/mera-public/marketing-v3.ts | 166 ++ .../src/app/mera-public/oferta/page.tsx | 11 +- .../frontend/src/app/mera-public/page.tsx | 11 +- .../src/app/mera-public/privacy/page.tsx | 11 +- .../src/app/mera-public/refund/page.tsx | 11 +- .../frontend/src/app/mera-public/v3/page.tsx | 56 + 19 files changed, 2909 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css create mode 100644 tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/v3/page.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx new file mode 100644 index 00000000..a240140b --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx @@ -0,0 +1,154 @@ +/** + * AccuracyV3 — «Точность»: KPI-плитки + таблица сверок прогноза с фактом + * сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент. + * + * KPI-значения — из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это + * плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения + * замером (см. шапку файла). + * + * Таблица сверок — `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было + * заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому + * строки — витринный пример формата отчёта, а не факт. Ровно поэтому подпись + * под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · …» — фраза, + * прямо утверждающая, что за строками стоят настоящие обезличенные сделки. + * Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись + * честно называет таблицу иллюстрацией формата, а вторую половину исходной + * фразы («"выше средней цены" — разница со средней ценой сделки по похожим + * квартирам в районе») сохраняет — она объясняет, ЧТО значит колонка, а не + * заявляет что-то о происхождении данных. + * + * Таблица размечена ARIA role="table/row/columnheader/cell" (а не ): + * на мобильном брейке (см. landing-v3.module.css) она превращается в карточки + * без горизонтального скролла, а implicit table-роли всё равно снимаются + * Safari при смене display у настоящей
— тот же квирк, что уже описан + * в HowItWorks.tsx для списков, только для таблиц его обходят явные роли, а + * не CSS-костыль. + */ + +import { + ACCURACY_PERIOD_LABEL, + ACCURACY_PLACEHOLDER, + PROOF_ROWS_PLACEHOLDER, +} from "../../marketing-v3"; +import styles from "../../landing-v3.module.css"; + +const KPI_TILES = [ + { + value: ACCURACY_PLACEHOLDER.priceError, + label: "средняя ошибка по цене", + note: ACCURACY_PLACEHOLDER.priceErrorNote, + }, + { + value: ACCURACY_PLACEHOLDER.daysError, + label: "точность по сроку продажи", + note: ACCURACY_PLACEHOLDER.daysErrorNote, + }, + { + value: ACCURACY_PLACEHOLDER.coverage, + label: "сделок попали в диапазон", + note: ACCURACY_PLACEHOLDER.coverageNote, + }, +] as const; + +export function AccuracyV3() { + return ( +
+
+
+ +

+ Мы сверяем каждый прогноз с реальной сделкой +

+

+ {`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`} +

+
+ +
    + {KPI_TILES.map((tile) => ( +
  • +
    {tile.value}
    +
    {tile.label}
    +
    {tile.note}
    +
  • + ))} +
  • +
    + {ACCURACY_PLACEHOLDER.checks} +
    +
    проверок за год
    +
    + {ACCURACY_PLACEHOLDER.checksNote} +
    +
  • +
+ +
+
+ Объект + Мера сказала + Факт сделки + Расхождение + + Выше средней цены по району + +
+ {PROOF_ROWS_PLACEHOLDER.map((row) => ( +
+
+ {row.addr} + {row.meta} +
+
+ {row.said} +
+
+ {row.fact} +
+
+ {row.err} +
+
+
+
{row.gain}
+
{row.gainPct}
+
+
+
+ ))} +
+

+ Таблица иллюстрирует формат отчёта, это не список конкретных сделок. + «Выше средней цены» — разница со средней ценой сделки по похожим + квартирам в районе. +

+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx new file mode 100644 index 00000000..7ef9ee8d --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx @@ -0,0 +1,140 @@ +"use client"; + +/** + * CityPicker — выбор города в шапке лэндинга v3 (макет ~ строки 47-73). + * + * Презентационный клиентский остров: сегодня публичный расчёт выключен + * (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь + * город никуда не отправляется — состояние живёт только в этом компоненте и + * показывает посетителю, что сервис знает больше одного города области. + * Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора + * наружу (проп/контекст) — в этом заходе scope ограничен шапкой/подвалом/ + * sticky-баром, самой формы здесь нет. + * + * Список городов и деление на «полное покрытие» / «данных меньше» берутся из + * `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно + * объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса» + * по городам (тысячи объявлений) — мы их не повторяем: таких цифр в коде нет, + * а гадать нельзя (правило честности в content.ts). + */ + +import { useEffect, useId, useRef, useState } from "react"; + +import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry"; + +import { PRIMARY_CITY, REGION_NAME } from "../../content"; +import styles from "../../landing-v3.module.css"; + +export function CityPicker() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [city, setCity] = useState(DEFAULT_CITY.label); + const wrapRef = useRef(null); + const buttonRef = useRef(null); + const panelId = useId(); + + // Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx. + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Escape — закрыть и вернуть фокус на кнопку. + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + const normalizedQuery = query.trim().toLowerCase(); + const filtered = normalizedQuery + ? OBLAST_CITIES.filter((c) => + c.label.toLowerCase().includes(normalizedQuery), + ) + : OBLAST_CITIES; + + return ( +
+ + + {open ? ( +
+
+ setQuery(e.target.value)} + placeholder="Поиск города" + aria-label="Поиск города" + /> +
+
+ {filtered.length > 0 ? ( + filtered.map((c) => ( + + )) + ) : ( +

+ Города нет в списке. Мы работаем там, где хватает данных по + сделкам — напишите нам, и добавим. +

+ )} +
+
+ ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "} + {REGION_NAME.toUpperCase()} +
+
+ ) : null} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx new file mode 100644 index 00000000..b7281015 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx @@ -0,0 +1,52 @@ +/** + * CostOfErrorV3 — «Цена ошибки»: три плитки про стоимость неверной цены + * (макет v3, ~строки 376-400, без id). Серверный компонент. + * + * Значения и подписи — из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts), + * заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) — тот же источник + * правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный + * литерал «150». + */ + +import { SERVICE_PRICE_RUB } from "../../content"; +import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3"; +import styles from "../../landing-v3.module.css"; + +export function CostOfErrorV3() { + return ( +
+
+
+ +

+ {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`} +

+
+ +
    + {COST_OF_ERROR_PLACEHOLDER.map((tile, index) => { + const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1; + return ( +
  • +
    {tile.value}
    +
    {tile.title}
    +

    + {tile.text} +

    +
  • + ); + })} +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx new file mode 100644 index 00000000..1ff10d5d --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx @@ -0,0 +1,145 @@ +/** + * FooterV3 — подвал лэндинга v3 (макет ~ строки 470-516): четыре колонки + + * нижняя строка с реквизитами. + * + * РЕКВИЗИТЫ И ДОКУМЕНТЫ — НЕ из макета (там маска «ООО «МЕРА» · ИНН + * 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`, + * `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` — тот же набор, + * что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в + * другой сетке. Документы включают Политику возврата и почту поддержки: + * макет их не показывал, а это обязательные элементы платного контура + * (маркировка эквайера, идентифицируемость оператора по 152-ФЗ). + * + * Способы оплаты (МИР / СБП / VISA / MASTERCARD в макете) НЕ рендерятся: + * приём платежей не подключён (см. TODO у нижней строки) — заявлять способы + * оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе». + * + * «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3): + * раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на + * этой же странице) — отдельного маршрута отчёта не существует. Оба + * внутренних якоря — через `/mera-public#...`: подвал рендерится и на + * оферте/возврате/ПДн, где голый `#check` никуда не долистал бы. + */ + +import Link from "next/link"; + +import { safeUrl } from "@/lib/safeUrl"; + +import { + LEGAL_ENTITY, + OFFER_PATH, + PRIVACY_PATH, + REFUND_PATH, + REGION_NAME, + SUPPORT_EMAIL, + SUPPORT_TELEGRAM_LABEL, + SUPPORT_TELEGRAM_URL, +} from "../../content"; +import styles from "../../landing-v3.module.css"; + +export function FooterV3() { + const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); + const year = new Date().getFullYear(); + + return ( +
+
+
+
+
+

+ Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "} + {REGION_NAME}. +

+
+ +
+

Разделы

+
+ + Главная + + + Проверить квартиру + + + Статьи + + + МЕРА для бизнеса + +
+
+ +
+

Связаться

+

+ Почта:{" "} + + {SUPPORT_EMAIL} + +

+ {telegramHref ? ( +

+ Поддержка в Telegram:{" "} + + {SUPPORT_TELEGRAM_LABEL} + +

+ ) : null} +
+ +
+

Документы

+
+ + Публичная оферта + + + Политика возврата + + + Обработка персональных данных + +
+
+
+ +
+
+ © {year} МЕРА + {LEGAL_ENTITY ? ( + + {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "} + {LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address} + + ) : null} + + Оценка носит информационный характер и не является офертой или + отчётом об оценке. + +
+ {/* TODO(платёжный контур): бейджи способов оплаты (МИР / СБП / VISA / + MASTERCARD, как в макете) — рендерить только когда приём оплаты + будет подключён backend'ом. Сегодня `PUBLIC_ESTIMATE_ENABLED = + false`, оплаты нет вовсе, а заявлять способы оплаты, которых нет, + — нарушение ст. 5 ФЗ «О рекламе». Гейт G6, см. + mera-b2c-paid-flow-decision.md. */} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx new file mode 100644 index 00000000..2adf9381 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -0,0 +1,303 @@ +"use client"; + +/** + * FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3, + * `МЕРА для себя v3.dc.html` строки ~129-170). + * + * ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена + * `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине: + * `rbac_guard` (backend/app/core/rbac.py) не пускает анонима на + * `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` — их нет в + * `_PUBLIC_PATHS`. Открытие анонимного периметра — отдельный backend-PR, вне + * границ этой задачи. Поэтому здесь: честная валидация 5 обязательных полей + * на клиенте + прямое сообщение «проверка ещё не открыта» вместо макетного + * сценария «спиннер 1100 мс → готовый результат». Тот сценарий на живом + * сайте отдавал бы одинаковые «14 объявлений / 47 дней» на любой введённый + * адрес — не продающая формулировка, а обман конкретного человека (см. шапку + * `../../marketing-v3.ts`). + * + * Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`, + * `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют + * состояния города, которым владеет шапка сайта — вне участка этой задачи + * («первый экран: герой и карточка»), и вносили бы числовые литералы (33%, + * 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`. + * + * Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена — см. + * TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`. + */ + +import { useId, useRef, useState } from "react"; +import type { FormEvent } from "react"; + +import { PRIMARY_CITY, PUBLIC_ESTIMATE_ENABLED } from "../../content"; +import styles from "../../landing-v3.module.css"; + +/** Три состояния квартиры — дословно из макета (строка 734 исходника). */ +const CONDITIONS = ["Требует ремонта", "Хорошее", "Отличное"] as const; +type Condition = (typeof CONDITIONS)[number]; + +type FieldName = "address" | "area" | "rooms" | "floor" | "condition"; + +type Feedback = + | { kind: "none" } + | { kind: "error"; field: FieldName; text: string } + | { kind: "info"; title: string; lines: readonly string[] }; + +const NONE: Feedback = { kind: "none" }; + +export function FreeCheckCard() { + const addressId = useId(); + const areaId = useId(); + const roomsId = useId(); + const floorId = useId(); + const conditionLabelId = useId(); + const feedbackId = useId(); + + const [address, setAddress] = useState(""); + const [area, setArea] = useState(""); + const [rooms, setRooms] = useState(""); + const [floor, setFloor] = useState(""); + const [condition, setCondition] = useState(""); + const [feedback, setFeedback] = useState(NONE); + + const addressRef = useRef(null); + const areaRef = useRef(null); + const roomsRef = useRef(null); + const floorRef = useRef(null); + const conditionGroupRef = useRef(null); + + function clearFeedback() { + setFeedback(NONE); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const trimmedAddress = address.trim(); + const trimmedArea = area.trim(); + const trimmedRooms = rooms.trim(); + const trimmedFloor = floor.trim(); + + if (!trimmedAddress) { + setFeedback({ + kind: "error", + field: "address", + text: "Укажите адрес — улицу и номер дома.", + }); + addressRef.current?.focus(); + return; + } + + if (!trimmedArea) { + setFeedback({ + kind: "error", + field: "area", + text: "Укажите площадь квартиры в квадратных метрах.", + }); + areaRef.current?.focus(); + return; + } + + if (!trimmedRooms) { + setFeedback({ + kind: "error", + field: "rooms", + text: "Укажите количество комнат.", + }); + roomsRef.current?.focus(); + return; + } + + if (!trimmedFloor) { + setFeedback({ + kind: "error", + field: "floor", + text: "Укажите этаж, например «7/16».", + }); + floorRef.current?.focus(); + return; + } + + if (!condition) { + setFeedback({ + kind: "error", + field: "condition", + text: "Выберите состояние квартиры.", + }); + conditionGroupRef.current?.focus(); + return; + } + + if (PUBLIC_ESTIMATE_ENABLED) { + // TODO(backend-периметр): здесь появится реальный запрос к + // POST /trade-in/coverage. По успешному ответу — переключить карточку в + // ветку результата и отрендерить `FreeResultV3` с полученными + // analogs/days (НЕ константами из marketing-v3.ts). Отдельный PR. + return; + } + + setFeedback({ + kind: "info", + title: "Бесплатную проверку по адресу мы пока не открыли", + lines: [ + `Дом принят: ${trimmedArea} м², ${trimmedRooms}-комн., этаж ${trimmedFloor}, состояние «${condition}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`, + "Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.", + ], + }); + } + + const errorField = feedback.kind === "error" ? feedback.field : null; + const describedBy = feedback.kind === "none" ? undefined : feedbackId; + + return ( +
+
+ Бесплатная проверка +
+ +
+
+ + +
+ + + + + +
+ +
+ + Состояние + +
+ {CONDITIONS.map((name) => ( + + ))} +
+
+ + +

+ БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД +

+ + {/* Живая область держим в DOM постоянно — регион, добавленный в + момент ошибки, часть скринридеров не озвучивает (тот же приём, + что в ../AddressForm.tsx). */} +
+ {feedback.kind === "error" && ( +
+

{feedback.text}

+
+ )} + {feedback.kind === "info" && ( +
+

{feedback.title}

+ {feedback.lines.map((line) => ( +

+ {line} +

+ ))} +
+ )} +
+ +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx new file mode 100644 index 00000000..5cbd9c78 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx @@ -0,0 +1,100 @@ +/** + * FreeResultV3 — ветка «результат» карточки «Бесплатная проверка» (макет v3, + * строки ~176-204 `МЕРА для себя v3.dc.html`). + * + * НЕ ВЫЗЫВАЕТСЯ СЕГОДНЯ НИОТКУДА. Компонент собран и типизирован, но нигде не + * рендерится в живом дереве: `FreeCheckCard.tsx` при сабмите (см. TODO рядом с + * `PUBLIC_ESTIMATE_ENABLED` там) всегда остаётся в форме и честно говорит, что + * онлайн-проверки ещё нет — потому что анонимных ручек `/geocode/suggest` и + * `/trade-in/estimate` не существует (см. `../../content.ts`). Как только + * появится `POST /trade-in/coverage`, эту ветку нужно подключить: завести + * `phase`-состояние в `FreeCheckCard`, по успешному ответу переключаться в + * `"result"` и рендерить этот компонент с РЕАЛЬНЫМИ `analogs`/`days` из ответа + * ручки — не константами. + * + * Данные приходят пропсами, а не читаются из `marketing-v3.ts` внутри + * компонента, — вызывающий код сегодня мог бы по ошибке подставить туда + * `FREE_SAMPLE_PLACEHOLDER` для любого адреса, и тогда «14 похожих объявлений» + * увидел бы каждый посетитель независимо от того, что он ввёл. Это уже не + * продающая формулировка, а обман конкретного человека — см. шапку + * `marketing-v3.ts`. Пропсовый контракт держит эту ответственность на + * вызывающей стороне и делает её явной в диффе, когда придёт время подключать + * реальные данные. + * + * Замыленная рублёвая цифра из макета («9,75 млн ₽», `filter: blur`) сюда + * НЕ перенесена вообще — вместо неё абстрактная плашка-скелет без единой + * цифры (см. `.heroResultSkeleton*` в `landing-v3.module.css`). + */ + +import styles from "../../landing-v3.module.css"; + +export interface FreeResultV3Props { + /** Число похожих объявлений — крупная цифра первой плитки, напр. "14". */ + analogs: string; + /** Подпись под числом объявлений. */ + analogsLabel: string; + /** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */ + days: string; + /** Подпись под сроком продажи. */ + daysLabel: string; + /** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */ + onBack: () => void; +} + +export function FreeResultV3({ + analogs, + analogsLabel, + days, + daysLabel, + onBack, +}: FreeResultV3Props) { + return ( +
+

ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ

+ +
+
+
{analogs}
+
{analogsLabel}
+
+
+
{days}
+
{daysLabel}
+
+
+ +
+ {/* Скелет без единой цифры: замыленную сумму из макета намеренно не + переносим, см. JSDoc выше. */} +