Merge origin/main (27e199e3) в fix/2683-manifest-drift
Конфликт modify/delete по tradein-mvp/backend/data/sql/_manifest_applied.txt
разрешён удалением — удаление и есть предмет PR. За трое суток в main дописали
три имени (240/250/251); дописывать их некуда: файла больше нет, а гейт
tests/test_migration_numbering.py берёт эталон применённого из origin/main.
Проверено, что удаление ничего не оставляет без потребителя: манифест не *.sql,
цикл миграций в deploy-tradein.yml и bootstrap схемы в ci-tradein.yml берут
glob '*.sql', новый scripts/check-migration-lock-timeout.py — тоже.
Замер дрейфа на 27e199e3: 216 имён в манифесте против 232 файлов, отставание
16 (было 15 на 07.08). Старый гейт на этом дереве: 4 passed.
# Conflicts:
# tradein-mvp/backend/data/sql/_manifest_applied.txt
This commit is contained in:
commit
9e5bcd3fa9
82 changed files with 4477 additions and 1265 deletions
|
|
@ -16,11 +16,43 @@ paths:
|
||||||
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
|
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout
|
||||||
|
|
||||||
-- DDL здесь (idempotent)
|
-- DDL здесь (idempotent)
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## lock_timeout при блокирующем DDL (обязательно)
|
||||||
|
|
||||||
|
Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) /
|
||||||
|
`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';`
|
||||||
|
сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml`
|
||||||
|
на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL).
|
||||||
|
|
||||||
|
**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX`
|
||||||
|
на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической
|
||||||
|
psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за
|
||||||
|
ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только
|
||||||
|
ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный
|
||||||
|
отказ, повторить позже) вместо тихой очереди перед приложением.
|
||||||
|
|
||||||
|
**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена
|
||||||
|
мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду,
|
||||||
|
поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит
|
||||||
|
очередь запросов приложения.
|
||||||
|
|
||||||
|
**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует):
|
||||||
|
`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через
|
||||||
|
VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение,
|
||||||
|
оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально
|
||||||
|
в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и
|
||||||
|
обрежет `CONCURRENTLY` ниже по файлу.
|
||||||
|
|
||||||
|
Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в
|
||||||
|
`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX
|
||||||
|
CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий.
|
||||||
|
|
||||||
## Idempotency (обязательно)
|
## Idempotency (обязательно)
|
||||||
|
|
||||||
- `CREATE TABLE IF NOT EXISTS`
|
- `CREATE TABLE IF NOT EXISTS`
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,15 @@ jobs:
|
||||||
python3 scripts/check-workflow-ports.py --selftest
|
python3 scripts/check-workflow-ports.py --selftest
|
||||||
python3 scripts/check-workflow-ports.py
|
python3 scripts/check-workflow-ports.py
|
||||||
|
|
||||||
|
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
|
||||||
|
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
|
||||||
|
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
|
||||||
|
# фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ
|
||||||
|
# каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину.
|
||||||
|
run: |
|
||||||
|
python3 scripts/check-migration-lock-timeout.py --selftest
|
||||||
|
python3 scripts/check-migration-lock-timeout.py
|
||||||
|
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,35 @@ jobs:
|
||||||
done
|
done
|
||||||
echo "All migrations applied."
|
echo "All migrations applied."
|
||||||
|
|
||||||
|
# (3b) Невалидные индексы после цикла (#2752). Оборванный
|
||||||
|
# CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
|
||||||
|
# планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка
|
||||||
|
# на записи всё равно платится. Молчит это так (воспроизведено на
|
||||||
|
# PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена
|
||||||
|
# применённой → следующий деплой прогоняет её заново → `CREATE INDEX
|
||||||
|
# CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает
|
||||||
|
# «relation already exists, skipping», выходит с кодом 0 → миграция
|
||||||
|
# помечается применённой, а индекс остаётся невалидным навсегда.
|
||||||
|
# Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она
|
||||||
|
# ловит и этот путь, и невалидные индексы любого другого
|
||||||
|
# происхождения (отменённый job, ручной CIC оператором).
|
||||||
|
# На 2026-08-07 на проде таких индексов 0 — это профилактика.
|
||||||
|
invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
|
||||||
|
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
|
||||||
|
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|
||||||
|
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
|
||||||
|
if [ -n "$invalid_idx" ]; then
|
||||||
|
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
|
||||||
|
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
|
||||||
|
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
|
||||||
|
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
|
||||||
|
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
|
||||||
|
echo " пересоздать индекс и повторить деплой."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ невалидных индексов нет."
|
||||||
|
|
||||||
# Bootstrap gendesign_reader password from env (post-migration, #976).
|
# Bootstrap gendesign_reader password from env (post-migration, #976).
|
||||||
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
|
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
|
||||||
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
|
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,31 @@ jobs:
|
||||||
done
|
done
|
||||||
echo "All migrations applied."
|
echo "All migrations applied."
|
||||||
|
|
||||||
|
# Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX
|
||||||
|
# CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ
|
||||||
|
# пользуется, а re-run миграции его не чинит — `CREATE INDEX
|
||||||
|
# CONCURRENTLY IF NOT EXISTS` печатает «relation already exists,
|
||||||
|
# skipping» и выходит с кодом 0, после чего миграция помечается
|
||||||
|
# применённой, а индекс остаётся битым навсегда (воспроизведено на
|
||||||
|
# PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY.
|
||||||
|
# Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07
|
||||||
|
# на проде таких индексов 0 — это профилактика.
|
||||||
|
invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
|
||||||
|
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
|
||||||
|
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|
||||||
|
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
|
||||||
|
if [ -n "$invalid_idx" ]; then
|
||||||
|
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
|
||||||
|
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
|
||||||
|
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
|
||||||
|
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
|
||||||
|
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
|
||||||
|
echo " пересоздать индекс и повторить деплой."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ невалидных индексов нет."
|
||||||
|
|
||||||
# Set tradein_fdw_reader password from env (post-migration bootstrap).
|
# Set tradein_fdw_reader password from env (post-migration bootstrap).
|
||||||
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
|
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
|
||||||
# password lives only in /opt/gendesign/backend/.env.runtime.
|
# password lives only in /opt/gendesign/backend/.env.runtime.
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800
|
||||||
SCRAPE_KN_DEFAULT_REGIONS=66
|
SCRAPE_KN_DEFAULT_REGIONS=66
|
||||||
# Путь к Playwright storage_state.json (commited в git, обновляется --save-state).
|
# Путь к Playwright storage_state.json (commited в git, обновляется --save-state).
|
||||||
SCRAPE_KN_STATE_PATH=data/playwright_state.json
|
SCRAPE_KN_STATE_PATH=data/playwright_state.json
|
||||||
# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен).
|
# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437,
|
||||||
# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep.
|
# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось
|
||||||
# Переменная сохранена в core/deps.py для быстрого rollback.
|
# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard
|
||||||
SCRAPE_ADMIN_TOKEN=
|
# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426).
|
||||||
|
|
|
||||||
|
|
@ -146,9 +146,6 @@ class Settings(BaseSettings):
|
||||||
# Path to a pre-captured Playwright storage_state.json (committed in repo,
|
# Path to a pre-captured Playwright storage_state.json (committed in repo,
|
||||||
# used by worker to skip cold-start WAF challenge).
|
# used by worker to skip cold-start WAF challenge).
|
||||||
scrape_kn_state_path: str = "data/playwright_state.json"
|
scrape_kn_state_path: str = "data/playwright_state.json"
|
||||||
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
|
|
||||||
# Empty string = endpoint disabled.
|
|
||||||
scrape_admin_token: str = ""
|
|
||||||
|
|
||||||
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
|
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
|
||||||
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
|
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
"""Shared FastAPI dependencies."""
|
|
||||||
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, status
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
def verify_admin_token(
|
|
||||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Verify admin token header. Raises 503 if not configured, 401 if invalid or missing."""
|
|
||||||
if not settings.scrape_admin_token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
|
|
||||||
)
|
|
||||||
if x_admin_token != settings.scrape_admin_token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
|
|
||||||
|
|
||||||
|
|
||||||
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]
|
|
||||||
|
|
@ -87,6 +87,12 @@ if settings.glitchtip_dsn:
|
||||||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||||
profiles_sample_rate=0.0,
|
profiles_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
|
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753).
|
||||||
|
# Дефолт SDK — True: при любом исключении кадр несёт значения аргументов
|
||||||
|
# (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event
|
||||||
|
# сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не
|
||||||
|
# дополнительная мера, а условие, без которого скраб не полон.
|
||||||
|
include_local_variables=False,
|
||||||
before_send=scrub_event,
|
before_send=scrub_event,
|
||||||
before_send_transaction=scrub_event,
|
before_send_transaction=scrub_event,
|
||||||
integrations=[
|
integrations=[
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,19 @@ import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sentry_sdk.integrations.logging import ignore_logger
|
||||||
from sentry_sdk.types import Event
|
from sentry_sdk.types import Event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753).
|
||||||
|
# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе
|
||||||
|
# в новое событие, которое снова пойдёт через этот же обработчик; при
|
||||||
|
# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено:
|
||||||
|
# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика
|
||||||
|
# остаётся в stdout контейнера: текст трассировки значений переменных не несёт.
|
||||||
|
ignore_logger(__name__)
|
||||||
|
|
||||||
_REDACTED = "[REDACTED]"
|
_REDACTED = "[REDACTED]"
|
||||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ
|
# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ
|
||||||
# (client_name/client_phone/client_email/phone/email/name, #396) + company/
|
# (client_name/client_phone/client_email/phone/email/name, #396) + company/
|
||||||
|
|
@ -143,6 +152,13 @@ def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None:
|
||||||
try:
|
try:
|
||||||
scrub_pii_event(event, hint)
|
scrub_pii_event(event, hint)
|
||||||
scrub_sensitive_query(event, hint)
|
scrub_sensitive_query(event, hint)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("sentry_scrub.scrub_event: handler failed, sending event as-is")
|
# Ни трассировки, ни str(exc): и то и другое способно нести значения из
|
||||||
|
# ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что
|
||||||
|
# защищает (#2753). Имя класса исключения данных не несёт. Событием
|
||||||
|
# мониторинга эта строка не станет — см. ignore_logger выше.
|
||||||
|
logger.error(
|
||||||
|
"sentry_scrub.scrub_event: handler failed (%s), sending event as-is",
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
return event
|
return event
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ if settings.glitchtip_dsn:
|
||||||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||||
profiles_sample_rate=0.0,
|
profiles_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
|
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см.
|
||||||
|
# app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно.
|
||||||
|
# В воркере вектор шире: задачи держат в кадрах сырые ответы источников.
|
||||||
|
include_local_variables=False,
|
||||||
before_send=scrub_event,
|
before_send=scrub_event,
|
||||||
before_send_transaction=scrub_event,
|
before_send_transaction=scrub_event,
|
||||||
integrations=[
|
integrations=[
|
||||||
|
|
|
||||||
146
backend/tests/_sentry_wiring_probe.py
Normal file
146
backend/tests/_sentry_wiring_probe.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py.
|
||||||
|
|
||||||
|
Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут
|
||||||
|
`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В
|
||||||
|
процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а
|
||||||
|
если бы и был достижим — глобальный клиент SDK остался бы живым для всех
|
||||||
|
последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает
|
||||||
|
вместе с ней.
|
||||||
|
|
||||||
|
Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а
|
||||||
|
DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются
|
||||||
|
случайно на каждый запуск — кадр стека несёт не только переменные, но и строки
|
||||||
|
исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной.
|
||||||
|
|
||||||
|
stdout — одна строка JSON: counts / scrub_handler_entries / markers / payloads
|
||||||
|
(тело каждого канала отдельно — см. `main`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
|
|
||||||
|
_FAILURE_CAP = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh(prefix: str) -> str:
|
||||||
|
return f"{prefix}-{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def _leaking_event(markers: dict[str, str]) -> dict[str, Any]:
|
||||||
|
"""Событие с ПДн в трёх местах, которые закрывает scrub_event."""
|
||||||
|
return {
|
||||||
|
"message": "sentry-wiring-probe",
|
||||||
|
"level": "error",
|
||||||
|
"request": {
|
||||||
|
"data": {"phone": markers["phone"], "message": markers["free_text"]},
|
||||||
|
"url": f"https://example.invalid/probe?api_key={markers['url_secret']}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(module: str) -> int:
|
||||||
|
importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init()
|
||||||
|
|
||||||
|
client = sentry_sdk.get_client()
|
||||||
|
sent: list[str] = []
|
||||||
|
|
||||||
|
def _record(envelope: Any) -> None:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
envelope.serialize_into(buf)
|
||||||
|
sent.append(buf.getvalue().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign]
|
||||||
|
|
||||||
|
markers = {
|
||||||
|
"phone": _fresh("probe-phone"),
|
||||||
|
"free_text": _fresh("probe-free-text"),
|
||||||
|
"url_secret": _fresh("probe-url-secret"),
|
||||||
|
"local_var": _fresh("probe-local-var"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Канал ошибок (before_send).
|
||||||
|
sentry_sdk.capture_event(_leaking_event(markers))
|
||||||
|
after_error = len(sent)
|
||||||
|
|
||||||
|
# 2. Канал транзакций (before_send_transaction) — Starlette кладёт
|
||||||
|
# request.data на transaction-scope так же, как на error-scope.
|
||||||
|
transaction = _leaking_event(markers)
|
||||||
|
transaction["type"] = "transaction"
|
||||||
|
transaction["transaction"] = "sentry-wiring-probe-tx"
|
||||||
|
transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}}
|
||||||
|
transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z"
|
||||||
|
transaction["timestamp"] = "2026-01-01T00:00:01.000000Z"
|
||||||
|
transaction["spans"] = []
|
||||||
|
sentry_sdk.capture_event(transaction)
|
||||||
|
after_transaction = len(sent)
|
||||||
|
|
||||||
|
# 3. Локальные переменные кадра стека (include_local_variables). Имя
|
||||||
|
# переменной произвольное — ключевой скраб такое не ловит по построению.
|
||||||
|
def _raise_with_local() -> None:
|
||||||
|
applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека
|
||||||
|
raise RuntimeError("sentry-wiring-probe boom")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_raise_with_local()
|
||||||
|
except RuntimeError:
|
||||||
|
sentry_sdk.capture_exception()
|
||||||
|
after_exception = len(sent)
|
||||||
|
|
||||||
|
# 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка
|
||||||
|
# журнала об отказе уходит в мониторинг через LoggingIntegration, снова
|
||||||
|
# попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса
|
||||||
|
# проверено: не завершается, 1000+ вложенных трассировок за минуту).
|
||||||
|
# Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе
|
||||||
|
# проба на сломанном коде висела бы вместо того, чтобы честно покраснеть.
|
||||||
|
from app.observability import sentry_scrub
|
||||||
|
|
||||||
|
original = sentry_scrub.scrub_pii_event
|
||||||
|
entries: list[int] = []
|
||||||
|
|
||||||
|
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
entries.append(1)
|
||||||
|
if len(entries) > _FAILURE_CAP:
|
||||||
|
return None
|
||||||
|
raise RuntimeError("sentry-wiring-probe scrubber failure")
|
||||||
|
|
||||||
|
sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
# Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is").
|
||||||
|
sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"})
|
||||||
|
finally:
|
||||||
|
sentry_scrub.scrub_pii_event = original # type: ignore[assignment]
|
||||||
|
after_scrub_failure = len(sent)
|
||||||
|
|
||||||
|
# Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные
|
||||||
|
# переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит
|
||||||
|
# чужую проверку и мешает понять, что именно сломано.
|
||||||
|
bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure]
|
||||||
|
names = ["error", "transaction", "exception", "scrub_failure"]
|
||||||
|
spans = dict(zip(names, itertools.pairwise(bounds), strict=True))
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"counts": {name: end - start for name, (start, end) in spans.items()},
|
||||||
|
"scrub_handler_entries": len(entries),
|
||||||
|
"markers": markers,
|
||||||
|
"payloads": {
|
||||||
|
name: "\n".join(sent[start:end]) for name, (start, end) in spans.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1]))
|
||||||
|
|
@ -15,10 +15,6 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
# Токен для тестов (не реальный)
|
|
||||||
ADMIN_TOKEN = "test-admin-token"
|
|
||||||
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None):
|
def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None):
|
||||||
"""Создать mock db session с преднастроенными ответами."""
|
"""Создать mock db session с преднастроенными ответами."""
|
||||||
|
|
@ -97,7 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]:
|
||||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_create_job_pilot_returns_job_id() -> None:
|
def test_create_job_pilot_returns_job_id() -> None:
|
||||||
"""POST /cadastre/jobs scope=pilot → job_id + targets_total."""
|
"""POST /cadastre/jobs scope=pilot → job_id + targets_total."""
|
||||||
quarters_50 = [f"66:41:{i:07d}" for i in range(50)]
|
quarters_50 = [f"66:41:{i:07d}" for i in range(50)]
|
||||||
|
|
@ -114,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/admin/cadastre/jobs",
|
"/api/v1/admin/cadastre/jobs",
|
||||||
json={"scope": "pilot"},
|
json={"scope": "pilot"},
|
||||||
headers=ADMIN_HEADERS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
|
|
@ -127,7 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_create_job_manual_list() -> None:
|
def test_create_job_manual_list() -> None:
|
||||||
"""POST /cadastre/jobs scope=manual_list с явным списком."""
|
"""POST /cadastre/jobs scope=manual_list с явным списком."""
|
||||||
db = _make_mock_db()
|
db = _make_mock_db()
|
||||||
|
|
@ -146,7 +139,6 @@ def test_create_job_manual_list() -> None:
|
||||||
"scope": "manual_list",
|
"scope": "manual_list",
|
||||||
"quarters": ["66:41:0303161", "66:41:0303162"],
|
"quarters": ["66:41:0303161", "66:41:0303162"],
|
||||||
},
|
},
|
||||||
headers=ADMIN_HEADERS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
|
|
@ -156,7 +148,6 @@ def test_create_job_manual_list() -> None:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
||||||
"""scope=manual_list без quarters → 400."""
|
"""scope=manual_list без quarters → 400."""
|
||||||
db = _make_mock_db()
|
db = _make_mock_db()
|
||||||
|
|
@ -170,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/admin/cadastre/jobs",
|
"/api/v1/admin/cadastre/jobs",
|
||||||
json={"scope": "manual_list"},
|
json={"scope": "manual_list"},
|
||||||
headers=ADMIN_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_list_jobs_returns_list() -> None:
|
def test_list_jobs_returns_list() -> None:
|
||||||
"""GET /cadastre/jobs → список jobs."""
|
"""GET /cadastre/jobs → список jobs."""
|
||||||
db = _make_mock_db(job_row=_make_sample_job_row())
|
db = _make_mock_db(job_row=_make_sample_job_row())
|
||||||
|
|
@ -194,7 +183,7 @@ def test_list_jobs_returns_list() -> None:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS)
|
response = client.get("/api/v1/admin/cadastre/jobs")
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert isinstance(body, list)
|
assert isinstance(body, list)
|
||||||
|
|
@ -206,7 +195,6 @@ def test_list_jobs_returns_list() -> None:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_get_job_not_found_returns_404() -> None:
|
def test_get_job_not_found_returns_404() -> None:
|
||||||
"""GET /cadastre/jobs/9999 → 404."""
|
"""GET /cadastre/jobs/9999 → 404."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -220,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS)
|
response = client.get("/api/v1/admin/cadastre/jobs/9999")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_cancel_job_success() -> None:
|
def test_cancel_job_success() -> None:
|
||||||
"""POST /cadastre/jobs/42/cancel → {cancelled: true}."""
|
"""POST /cadastre/jobs/42/cancel → {cancelled: true}."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -243,7 +230,6 @@ def test_cancel_job_success() -> None:
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/admin/cadastre/jobs/42/cancel",
|
"/api/v1/admin/cadastre/jobs/42/cancel",
|
||||||
headers=ADMIN_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|
@ -253,7 +239,6 @@ def test_cancel_job_success() -> None:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_cancel_job_not_found_returns_404() -> None:
|
def test_cancel_job_not_found_returns_404() -> None:
|
||||||
"""POST /cadastre/jobs/9999/cancel когда job не найден → 404."""
|
"""POST /cadastre/jobs/9999/cancel когда job не найден → 404."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -270,7 +255,6 @@ def test_cancel_job_not_found_returns_404() -> None:
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/admin/cadastre/jobs/9999/cancel",
|
"/api/v1/admin/cadastre/jobs/9999/cancel",
|
||||||
headers=ADMIN_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
- валидный запрос без year → scope all_years_2022_2026, task_id в ответе
|
- валидный запрос без year → scope all_years_2022_2026, task_id в ответе
|
||||||
- валидный запрос с year=2026 → scope year_2026
|
- валидный запрос с year=2026 → scope year_2026
|
||||||
- year < 2022 или > 2030 → 422
|
- year < 2022 или > 2030 → 422
|
||||||
- отсутствие X-Admin-Token → 401/503
|
|
||||||
|
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие
|
||||||
|
X-Admin-Token → 401/503» стояла в этом докстринге, но соответствующего теста в
|
||||||
|
файле нет. Гейт `/api/v1/admin/*` — middleware `rbac_guard` (app/main.py),
|
||||||
|
покрыт tests/test_rbac.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -16,8 +20,6 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
ADMIN_TOKEN = "test-admin-token"
|
|
||||||
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
|
|
||||||
ENDPOINT = "/api/v1/admin/scrape/ekburg-permits"
|
ENDPOINT = "/api/v1/admin/scrape/ekburg-permits"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_trigger_refresh_all_returns_task_id() -> None:
|
def test_trigger_refresh_all_returns_task_id() -> None:
|
||||||
"""POST без year → refresh_all queued, scope=all_years_2022_2026."""
|
"""POST без year → refresh_all queued, scope=all_years_2022_2026."""
|
||||||
mock_result = _mock_task("task-all-001")
|
mock_result = _mock_task("task-all-001")
|
||||||
|
|
@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None:
|
||||||
):
|
):
|
||||||
mock_refresh_all.apply_async.return_value = mock_result
|
mock_refresh_all.apply_async.return_value = mock_result
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS)
|
response = client.post(ENDPOINT, json={})
|
||||||
|
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|
@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None:
|
||||||
assert "queued_at" in body
|
assert "queued_at" in body
|
||||||
|
|
||||||
|
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_trigger_refresh_year_returns_task_id() -> None:
|
def test_trigger_refresh_year_returns_task_id() -> None:
|
||||||
"""POST year=2026 → refresh_year queued, scope=year_2026."""
|
"""POST year=2026 → refresh_year queued, scope=year_2026."""
|
||||||
mock_result = _mock_task("task-year-002")
|
mock_result = _mock_task("task-year-002")
|
||||||
|
|
@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None:
|
||||||
):
|
):
|
||||||
mock_refresh_year.apply_async.return_value = mock_result
|
mock_refresh_year.apply_async.return_value = mock_result
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS)
|
response = client.post(ENDPOINT, json={"year": 2026})
|
||||||
|
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|
@ -67,9 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999])
|
@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999])
|
||||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
|
||||||
def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
|
def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
|
||||||
"""year вне диапазона [2022, 2030] → 422 Unprocessable Entity."""
|
"""year вне диапазона [2022, 2030] → 422 Unprocessable Entity."""
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS)
|
response = client.post(ENDPOINT, json={"year": bad_year})
|
||||||
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
|
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
|
||||||
|
|
|
||||||
|
|
@ -197,32 +197,74 @@ class TestParseBabushkinaBlock:
|
||||||
assert site.krt_kind == "жилой застройки"
|
assert site.krt_kind == "жилой застройки"
|
||||||
|
|
||||||
|
|
||||||
class TestMultiSiteCountFromDump:
|
# ── Многоблочная страница: два года, три площадки, повторяющийся номер ────────
|
||||||
"""Проверка количества площадок из реального дампа страницы."""
|
#
|
||||||
|
# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший
|
||||||
|
# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у
|
||||||
|
# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а
|
||||||
|
# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому
|
||||||
|
# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ.
|
||||||
|
#
|
||||||
|
# Свойство, ради которого они писались, от дампа не зависит и выражается
|
||||||
|
# страницей из блоков, которые уже лежат в этом файле:
|
||||||
|
# • несколько <h2>-секций с годами разбираются в одном проходе;
|
||||||
|
# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ,
|
||||||
|
# поэтому «2.» встречается дважды — и site_name всё равно обязан быть
|
||||||
|
# уникальным (номер сам по себе ключом не является);
|
||||||
|
# • блоки лежат внутри контейнера <div> — это ветка `_extract_krt_section`
|
||||||
|
# через `find_parent`, которой одиночные блоки выше не касаются вовсе.
|
||||||
|
#
|
||||||
|
# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778):
|
||||||
|
# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора
|
||||||
|
# реального документа, синтетической страницей оно не заменяется. На проде
|
||||||
|
# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но
|
||||||
|
# автоматической проверки этого больше нет — она вернётся, когда в репозитории
|
||||||
|
# появится образец страницы.
|
||||||
|
_MULTISITE_PAGE = (
|
||||||
|
"<div class='content'>"
|
||||||
|
+ _BRUSNIKA_BLOCK
|
||||||
|
+ _BABUSHKINA_BLOCK
|
||||||
|
+ textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
<p> <strong>2. КРТ нежилой застройки в районе ул. Краснофлотцев:</strong></p>
|
||||||
|
<p>Площадь территории: 3,65 га;<br />\
|
||||||
|
Градостроительный потенциал: 117 640 кв. м;<br />\
|
||||||
|
Всего многоквартирных домов: 13.</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
+ "</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiSitePage:
|
||||||
|
"""Разбор страницы из нескольких блоков — без локального дампа."""
|
||||||
|
|
||||||
@pytest.fixture(scope="class")
|
@pytest.fixture(scope="class")
|
||||||
def dump_html(self):
|
def sites(self):
|
||||||
"""Загружает локальный дамп страницы.
|
return parse_krt_page(_MULTISITE_PAGE)
|
||||||
|
|
||||||
Тест пропускается если файл отсутствует (CI без дампа).
|
def test_all_three_sites_parsed(self, sites):
|
||||||
"""
|
"""Три блока в двух годовых секциях → три площадки."""
|
||||||
import os
|
assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}"
|
||||||
|
|
||||||
dump_path = os.path.join(
|
def test_no_duplicate_site_names(self, sites):
|
||||||
os.path.dirname(__file__),
|
"""Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны."""
|
||||||
"../../../../.playwright-mcp/ekb-docs/krt_page_raw.html",
|
|
||||||
)
|
|
||||||
if not os.path.exists(dump_path):
|
|
||||||
pytest.skip("krt_page_raw.html dump not found — skip real-dump test")
|
|
||||||
with open(dump_path, encoding="utf-8") as fh:
|
|
||||||
return fh.read()
|
|
||||||
|
|
||||||
def test_site_count_from_dump(self, dump_html):
|
|
||||||
"""Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15)."""
|
|
||||||
sites = parse_krt_page(dump_html)
|
|
||||||
assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}"
|
|
||||||
|
|
||||||
def test_no_duplicate_site_names(self, dump_html):
|
|
||||||
sites = parse_krt_page(dump_html)
|
|
||||||
names = [s.site_name for s in sites]
|
names = [s.site_name for s in sites]
|
||||||
assert len(names) == len(set(names)), "Дубликаты site_name в результате"
|
assert sum(n.startswith("2.") for n in names) == 2, names
|
||||||
|
assert len(names) == len(set(names)), f"Дубликаты site_name: {names}"
|
||||||
|
|
||||||
|
def test_blocks_do_not_leak_into_each_other(self, sites):
|
||||||
|
"""Границы блоков соблюдены: договор и застройщик остались у своей площадки.
|
||||||
|
|
||||||
|
Регресс, который этим ловится: если block_end съезжает, «Брусника» и
|
||||||
|
договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему.
|
||||||
|
"""
|
||||||
|
with_developer = [s for s in sites if s.developer_name]
|
||||||
|
assert len(with_developer) == 1, [s.developer_name for s in sites]
|
||||||
|
assert "Брусника" in with_developer[0].developer_name
|
||||||
|
assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1
|
||||||
|
|
||||||
|
def test_kinds_are_read_per_block(self, sites):
|
||||||
|
"""Третий блок — «нежилой застройки», не должен унаследовать вид соседа."""
|
||||||
|
kinds = sorted(s.krt_kind for s in sites if s.krt_kind)
|
||||||
|
assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@
|
||||||
DNS-fail повторяет timeout на каждый analyze.
|
DNS-fail повторяет timeout на каждый analyze.
|
||||||
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
|
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
|
||||||
раздельные таблицы внутри модуля).
|
раздельные таблицы внутри модуля).
|
||||||
4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start →
|
4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на
|
||||||
ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store).
|
поток, все получают одно и то же значение, и шторм заканчивается сложившимся
|
||||||
|
кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно
|
||||||
|
(#1370, см. сам тест).
|
||||||
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
|
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
|
||||||
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
|
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
|
||||||
|
|
||||||
|
|
@ -23,6 +25,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate:
|
||||||
|
|
||||||
|
|
||||||
class TestConcurrencySafe:
|
class TestConcurrencySafe:
|
||||||
def test_single_flight_cold_start_one_network_call(self) -> None:
|
def test_cold_start_storm_bounded_and_cache_converges(self) -> None:
|
||||||
"""16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов."""
|
"""16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток,
|
||||||
# GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock.
|
все получают одно и то же значение, и после шторма кэш отвечает без сети.
|
||||||
|
|
||||||
|
ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это
|
||||||
|
было требование, которого код НЕ выполняет и выполнять не собирается:
|
||||||
|
сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze
|
||||||
|
сериализуются на время httpx-вызова даже для разных координат), а рядом с
|
||||||
|
ним написано, что cold-start на один ключ «может породить несколько
|
||||||
|
параллельных запросов… приемлемо». Тест зеленел не потому, что защита
|
||||||
|
работает, а потому что при GIL первый поток обычно успевал сложить
|
||||||
|
результат раньше остальных.
|
||||||
|
|
||||||
|
Замер 2026-08-07, 200 штормов подряд: при дефолтном
|
||||||
|
`sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые
|
||||||
|
~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов,
|
||||||
|
было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда
|
||||||
|
потоки реально чередуются, больше одного вызова дали 197 штормов из 200,
|
||||||
|
и в 173 из них вызовов было все 16. То есть утверждение ложно почти
|
||||||
|
всегда, когда гонка вообще случается, — чинить надо было тест.
|
||||||
|
|
||||||
|
Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ
|
||||||
|
стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние
|
||||||
|
запросы бывают только на cold-start одного ключа и они идемпотентны.
|
||||||
|
Понадобится — это отдельная задача с отдельным обоснованием, а не
|
||||||
|
побочный эффект правки теста.
|
||||||
|
|
||||||
|
`time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти
|
||||||
|
промах кэша до первой записи. Так тест мерит худший случай той самой
|
||||||
|
уступки, а не везение планировщика.
|
||||||
|
"""
|
||||||
start_barrier = threading.Barrier(16)
|
start_barrier = threading.Barrier(16)
|
||||||
get_call_count = 0
|
get_call_count = 0
|
||||||
get_lock = threading.Lock()
|
get_lock = threading.Lock()
|
||||||
|
|
@ -253,8 +284,7 @@ class TestConcurrencySafe:
|
||||||
nonlocal get_call_count
|
nonlocal get_call_count
|
||||||
with get_lock:
|
with get_lock:
|
||||||
get_call_count += 1
|
get_call_count += 1
|
||||||
# Микро-задержка — окно для других потоков добраться до lock'а.
|
time.sleep(0.05) # окно, в котором остальные потоки видят промах
|
||||||
# Не делаем sleep большим, чтобы тест не висел.
|
|
||||||
return _make_httpx_response(_make_forecast_response())
|
return _make_httpx_response(_make_forecast_response())
|
||||||
|
|
||||||
client_ctx = MagicMock()
|
client_ctx = MagicMock()
|
||||||
|
|
@ -276,11 +306,27 @@ class TestConcurrencySafe:
|
||||||
t.start()
|
t.start()
|
||||||
for t in threads:
|
for t in threads:
|
||||||
t.join()
|
t.join()
|
||||||
|
storm_calls = get_call_count
|
||||||
|
# Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что
|
||||||
|
# поход в сеть был бы виден счётчиком, а не отказом коннекта.
|
||||||
|
after_storm = weather_cache.get_weather_cached(56.84, 60.59)
|
||||||
|
|
||||||
assert len(results) == 16
|
assert len(results) == 16
|
||||||
assert all(r is not None for r in results)
|
assert results[0] is not None
|
||||||
# Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов.
|
assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения"
|
||||||
assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}"
|
# Потолок — число участников: в сеть идут только промахнувшиеся, по разу
|
||||||
|
# каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная
|
||||||
|
# запись в кэш); меньше единицы невозможно, кэш был пуст.
|
||||||
|
assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках"
|
||||||
|
# Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз:
|
||||||
|
# следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен
|
||||||
|
# снятого single-flight — без этого уступка превращается в дыру.
|
||||||
|
assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)]
|
||||||
|
assert after_storm == results[0]
|
||||||
|
assert get_call_count == storm_calls, (
|
||||||
|
f"после шторма кэш обязан отвечать без сети, а вызовов стало "
|
||||||
|
f"{get_call_count} против {storm_calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -73,14 +73,22 @@ tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb
|
||||||
tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real
|
tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real
|
||||||
tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped
|
tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped
|
||||||
|
|
||||||
# ── Образцы, которых нет в git ────────────────────────────────────────────────
|
# ── Образец, которого нет в git ───────────────────────────────────────────────
|
||||||
# ДОЛГ, а не норма: парсеры проверяются на реальных дампах, которые лежат только
|
# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py::
|
||||||
# в untracked `.playwright-mcp/`. Пока файлы не в репозитории, эти 8 проверок не
|
# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не
|
||||||
# исполняются нигде. Правильная починка — закоммитить образцы в tests/fixtures/
|
# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит
|
||||||
# (как сделано в tradein-mvp/backend/tests/fixtures/*.html), после чего строки
|
# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), —
|
||||||
# отсюда убрать.
|
# зато свойство, которое они проверяли (несколько блоков на странице, уникальные
|
||||||
tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_no_duplicate_site_names
|
# site_name), выразимо без дампа и теперь проверяется на многоблочной странице,
|
||||||
tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_site_count_from_dump
|
# собранной из блоков, уже лежащих в том же тест-файле.
|
||||||
|
#
|
||||||
|
# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF
|
||||||
|
# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком.
|
||||||
|
# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) —
|
||||||
|
# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить
|
||||||
|
# нет: официальный градостроительный документ, без персональных данных.
|
||||||
|
# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`,
|
||||||
|
# убрать эти шесть строк.
|
||||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area
|
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area
|
||||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows
|
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows
|
||||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty
|
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,15 @@
|
||||||
- GET /{id} → 200 / 404
|
- GET /{id} → 200 / 404
|
||||||
- PUT /{id} → 200 / 404
|
- PUT /{id} → 200 / 404
|
||||||
- DELETE /{id} → 204 / 404
|
- DELETE /{id} → 204 / 404
|
||||||
- 401 при отсутствии X-Admin-Token
|
|
||||||
- 422 при невалидных weights (неизвестная категория, вес вне диапазона)
|
- 422 при невалидных weights (неизвестная категория, вес вне диапазона)
|
||||||
|
|
||||||
|
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при
|
||||||
|
отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в
|
||||||
|
файле нет — заголовок просто отправлялся во все запросы и никем не читался
|
||||||
|
(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware
|
||||||
|
`rbac_guard` (app/main.py, `_ADMIN_API_RE` → 403 для role != admin) и покрыт
|
||||||
|
tests/test_rbac.py.
|
||||||
|
|
||||||
Mock-based: get_db переопределяется через dependency override.
|
Mock-based: get_db переопределяется через dependency override.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -24,9 +30,6 @@ from app.core.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.services.site_finder.weight_profiles import WeightProfile
|
from app.services.site_finder.weight_profiles import WeightProfile
|
||||||
|
|
||||||
_ADMIN_TOKEN = "test-admin-token"
|
|
||||||
_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN}
|
|
||||||
|
|
||||||
_NOW = datetime.now(UTC)
|
_NOW = datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -50,9 +53,7 @@ def _make_profile(
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
def client() -> TestClient:
|
||||||
"""TestClient с переопределённым SCRAPE_ADMIN_TOKEN."""
|
|
||||||
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,7 +77,7 @@ def _clear_overrides():
|
||||||
# ── GET list ───────────────────────────────────────────────────────────────────
|
# ── GET list ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""GET ?user_id= → 200 + пустой список."""
|
"""GET ?user_id= → 200 + пустой список."""
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
_override_db(mock)
|
_override_db(mock)
|
||||||
|
|
@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
|
||||||
"app.api.v1.admin_weight_profiles.list_profiles",
|
"app.api.v1.admin_weight_profiles.list_profiles",
|
||||||
lambda db, user_id: [],
|
lambda db, user_id: [],
|
||||||
)
|
)
|
||||||
r = client_with_token.get(
|
r = client.get(
|
||||||
"/api/v1/admin/site-finder/weight-profiles",
|
"/api/v1/admin/site-finder/weight-profiles",
|
||||||
params={"user_id": "user-x"},
|
params={"user_id": "user-x"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json() == []
|
assert r.json() == []
|
||||||
|
|
@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
|
||||||
_clear_overrides()
|
_clear_overrides()
|
||||||
|
|
||||||
|
|
||||||
def test_list_returns_profiles(
|
def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""GET ?user_id= → 200 + список профилей."""
|
"""GET ?user_id= → 200 + список профилей."""
|
||||||
profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")]
|
profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")]
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
|
|
@ -108,10 +106,9 @@ def test_list_returns_profiles(
|
||||||
"app.api.v1.admin_weight_profiles.list_profiles",
|
"app.api.v1.admin_weight_profiles.list_profiles",
|
||||||
lambda db, user_id: profiles,
|
lambda db, user_id: profiles,
|
||||||
)
|
)
|
||||||
r = client_with_token.get(
|
r = client.get(
|
||||||
"/api/v1/admin/site-finder/weight-profiles",
|
"/api/v1/admin/site-finder/weight-profiles",
|
||||||
params={"user_id": "user-1"},
|
params={"user_id": "user-1"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
|
@ -124,7 +121,7 @@ def test_list_returns_profiles(
|
||||||
# ── POST create ────────────────────────────────────────────────────────────────
|
# ── POST create ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""POST создаёт профиль, возвращает его со статусом 201."""
|
"""POST создаёт профиль, возвращает его со статусом 201."""
|
||||||
created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5})
|
created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5})
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
|
|
@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
||||||
"app.api.v1.admin_weight_profiles.create_profile",
|
"app.api.v1.admin_weight_profiles.create_profile",
|
||||||
lambda db, payload: created,
|
lambda db, payload: created,
|
||||||
)
|
)
|
||||||
r = client_with_token.post(
|
r = client.post(
|
||||||
"/api/v1/admin/site-finder/weight-profiles",
|
"/api/v1/admin/site-finder/weight-profiles",
|
||||||
json={
|
json={
|
||||||
"user_id": "user-1",
|
"user_id": "user-1",
|
||||||
|
|
@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
||||||
"weights": {"school": 2.0, "park": 1.5},
|
"weights": {"school": 2.0, "park": 1.5},
|
||||||
"is_default": False,
|
"is_default": False,
|
||||||
},
|
},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
|
@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
||||||
_clear_overrides()
|
_clear_overrides()
|
||||||
|
|
||||||
|
|
||||||
def test_create_validation_unknown_category(client_with_token: TestClient) -> None:
|
def test_create_validation_unknown_category(client: TestClient) -> None:
|
||||||
"""POST с неизвестной POI-категорией → 422 (Pydantic validation)."""
|
"""POST с неизвестной POI-категорией → 422 (Pydantic validation)."""
|
||||||
r = client_with_token.post(
|
r = client.post(
|
||||||
"/api/v1/admin/site-finder/weight-profiles",
|
"/api/v1/admin/site-finder/weight-profiles",
|
||||||
json={
|
json={
|
||||||
"user_id": "user-1",
|
"user_id": "user-1",
|
||||||
"profile_name": "Bad",
|
"profile_name": "Bad",
|
||||||
"weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES
|
"weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES
|
||||||
},
|
},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None:
|
def test_create_validation_weight_out_of_bounds(client: TestClient) -> None:
|
||||||
"""POST с весом вне [-2, 3] → 422."""
|
"""POST с весом вне [-2, 3] → 422."""
|
||||||
r = client_with_token.post(
|
r = client.post(
|
||||||
"/api/v1/admin/site-finder/weight-profiles",
|
"/api/v1/admin/site-finder/weight-profiles",
|
||||||
json={
|
json={
|
||||||
"user_id": "user-1",
|
"user_id": "user-1",
|
||||||
"profile_name": "Bad",
|
"profile_name": "Bad",
|
||||||
"weights": {"school": 99.0},
|
"weights": {"school": 99.0},
|
||||||
},
|
},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -
|
||||||
# ── GET one ────────────────────────────────────────────────────────────────────
|
# ── GET one ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""GET /{id}?user_id= → 200."""
|
"""GET /{id}?user_id= → 200."""
|
||||||
profile = _make_profile(7)
|
profile = _make_profile(7)
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
|
|
@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
|
||||||
"app.api.v1.admin_weight_profiles.get_profile",
|
"app.api.v1.admin_weight_profiles.get_profile",
|
||||||
lambda db, user_id, profile_id: profile,
|
lambda db, user_id, profile_id: profile,
|
||||||
)
|
)
|
||||||
r = client_with_token.get(
|
r = client.get(
|
||||||
"/api/v1/admin/site-finder/weight-profiles/7",
|
"/api/v1/admin/site-finder/weight-profiles/7",
|
||||||
params={"user_id": "user-1"},
|
params={"user_id": "user-1"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["id"] == 7
|
assert r.json()["id"] == 7
|
||||||
|
|
@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
|
||||||
_clear_overrides()
|
_clear_overrides()
|
||||||
|
|
||||||
|
|
||||||
def test_get_profile_not_found(
|
def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""GET /{id} несуществующего профиля → 404."""
|
"""GET /{id} несуществующего профиля → 404."""
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
_override_db(mock)
|
_override_db(mock)
|
||||||
|
|
@ -216,10 +207,9 @@ def test_get_profile_not_found(
|
||||||
"app.api.v1.admin_weight_profiles.get_profile",
|
"app.api.v1.admin_weight_profiles.get_profile",
|
||||||
lambda db, user_id, profile_id: None,
|
lambda db, user_id, profile_id: None,
|
||||||
)
|
)
|
||||||
r = client_with_token.get(
|
r = client.get(
|
||||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||||
params={"user_id": "user-1"},
|
params={"user_id": "user-1"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -229,7 +219,7 @@ def test_get_profile_not_found(
|
||||||
# ── PUT update ─────────────────────────────────────────────────────────────────
|
# ── PUT update ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""PUT /{id} → 200 + обновлённый профиль."""
|
"""PUT /{id} → 200 + обновлённый профиль."""
|
||||||
updated = _make_profile(3, profile_name="Обновлённый")
|
updated = _make_profile(3, profile_name="Обновлённый")
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
|
|
@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
|
||||||
"app.api.v1.admin_weight_profiles.update_profile",
|
"app.api.v1.admin_weight_profiles.update_profile",
|
||||||
lambda db, user_id, profile_id, payload: updated,
|
lambda db, user_id, profile_id, payload: updated,
|
||||||
)
|
)
|
||||||
r = client_with_token.put(
|
r = client.put(
|
||||||
"/api/v1/admin/site-finder/weight-profiles/3",
|
"/api/v1/admin/site-finder/weight-profiles/3",
|
||||||
params={"user_id": "user-1"},
|
params={"user_id": "user-1"},
|
||||||
json={"profile_name": "Обновлённый"},
|
json={"profile_name": "Обновлённый"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["profile_name"] == "Обновлённый"
|
assert r.json()["profile_name"] == "Обновлённый"
|
||||||
|
|
@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
|
||||||
_clear_overrides()
|
_clear_overrides()
|
||||||
|
|
||||||
|
|
||||||
def test_update_profile_not_found(
|
def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""PUT /{id} несуществующего → 404."""
|
"""PUT /{id} несуществующего → 404."""
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
_override_db(mock)
|
_override_db(mock)
|
||||||
|
|
@ -262,11 +249,10 @@ def test_update_profile_not_found(
|
||||||
"app.api.v1.admin_weight_profiles.update_profile",
|
"app.api.v1.admin_weight_profiles.update_profile",
|
||||||
lambda db, user_id, profile_id, payload: None,
|
lambda db, user_id, profile_id, payload: None,
|
||||||
)
|
)
|
||||||
r = client_with_token.put(
|
r = client.put(
|
||||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||||
params={"user_id": "user-1"},
|
params={"user_id": "user-1"},
|
||||||
json={"profile_name": "X"},
|
json={"profile_name": "X"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -276,7 +262,7 @@ def test_update_profile_not_found(
|
||||||
# ── DELETE ─────────────────────────────────────────────────────────────────────
|
# ── DELETE ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""DELETE /{id} → 204."""
|
"""DELETE /{id} → 204."""
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
_override_db(mock)
|
_override_db(mock)
|
||||||
|
|
@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke
|
||||||
"app.api.v1.admin_weight_profiles.delete_profile",
|
"app.api.v1.admin_weight_profiles.delete_profile",
|
||||||
lambda db, user_id, profile_id: True,
|
lambda db, user_id, profile_id: True,
|
||||||
)
|
)
|
||||||
r = client_with_token.delete(
|
r = client.delete(
|
||||||
"/api/v1/admin/site-finder/weight-profiles/5",
|
"/api/v1/admin/site-finder/weight-profiles/5",
|
||||||
params={"user_id": "user-1"},
|
params={"user_id": "user-1"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
finally:
|
finally:
|
||||||
_clear_overrides()
|
_clear_overrides()
|
||||||
|
|
||||||
|
|
||||||
def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""DELETE /{id} несуществующего → 404."""
|
"""DELETE /{id} несуществующего → 404."""
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
_override_db(mock)
|
_override_db(mock)
|
||||||
|
|
@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
|
||||||
"app.api.v1.admin_weight_profiles.delete_profile",
|
"app.api.v1.admin_weight_profiles.delete_profile",
|
||||||
lambda db, user_id, profile_id: False,
|
lambda db, user_id, profile_id: False,
|
||||||
)
|
)
|
||||||
r = client_with_token.delete(
|
r = client.delete(
|
||||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||||
params={"user_id": "user-1"},
|
params={"user_id": "user-1"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
|
||||||
|
|
||||||
|
|
||||||
def test_list_include_system_calls_with_system(
|
def test_list_include_system_calls_with_system(
|
||||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""GET ?include_system=true вызывает list_profiles_with_system, возвращает presets."""
|
"""GET ?include_system=true вызывает list_profiles_with_system, возвращает presets."""
|
||||||
system_profile = _make_profile(
|
system_profile = _make_profile(
|
||||||
|
|
@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system(
|
||||||
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
|
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
|
||||||
lambda db, user_id: [user_profile, system_profile],
|
lambda db, user_id: [user_profile, system_profile],
|
||||||
)
|
)
|
||||||
r = client_with_token.get(
|
r = client.get(
|
||||||
"/api/v1/admin/site-finder/weight-profiles",
|
"/api/v1/admin/site-finder/weight-profiles",
|
||||||
params={"user_id": "user-1", "include_system": "true"},
|
params={"user_id": "user-1", "include_system": "true"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
|
@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system(
|
||||||
|
|
||||||
|
|
||||||
def test_list_without_include_system_does_not_call_with_system(
|
def test_list_without_include_system_does_not_call_with_system(
|
||||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""GET без include_system → list_profiles (только пользовательские профили)."""
|
"""GET без include_system → list_profiles (только пользовательские профили)."""
|
||||||
user_profile = _make_profile(1, user_id="user-1")
|
user_profile = _make_profile(1, user_id="user-1")
|
||||||
|
|
@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system(
|
||||||
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
|
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
|
||||||
lambda db, user_id: called_with_system.append(True) or [],
|
lambda db, user_id: called_with_system.append(True) or [],
|
||||||
)
|
)
|
||||||
r = client_with_token.get(
|
r = client.get(
|
||||||
"/api/v1/admin/site-finder/weight-profiles",
|
"/api/v1/admin/site-finder/weight-profiles",
|
||||||
params={"user_id": "user-1"},
|
params={"user_id": "user-1"},
|
||||||
headers=_HEADERS,
|
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert len(r.json()) == 1
|
assert len(r.json()) == 1
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,21 @@
|
||||||
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
|
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
|
||||||
что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event
|
что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event
|
||||||
redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/
|
redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/
|
||||||
company/message) из request.data/extra/contexts, и что composed-хендлер
|
company/message) из request.data/extra/contexts (#2457-review), и — в конце
|
||||||
scrub_event реально повешен на ОБА канала (before_send И
|
файла — что до транспорта не доезжают ни ПДн тела запроса, ни значения
|
||||||
before_send_transaction) в main.py/celery_app.py (#2457-review).
|
локальных переменных кадра стека, ни второе событие о сбое самого скраба
|
||||||
|
(#2753, поведение через подставной транспорт вместо поиска подстроки).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from functools import lru_cache
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
|
||||||
_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
@ -392,26 +398,102 @@ def test_scrub_event_survives_scrub_sensitive_query_exception() -> None:
|
||||||
assert result is event
|
assert result is event
|
||||||
|
|
||||||
|
|
||||||
# ── wiring: before_send/before_send_transaction реально используют scrub_event ──
|
# ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ─────
|
||||||
#
|
#
|
||||||
# Source-grep вместо мока sentry_sdk.init: main.py/celery_app.py вызывают
|
# До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в
|
||||||
# sentry_sdk.init() на module-level import, поэтому мок пришлось бы ставить ДО
|
# файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут
|
||||||
# импорта app.main — фрагильно и не переиспользуемо между тестами (модуль уже
|
# многострочные комментарии, где те же подстроки встречаются, — достаточно
|
||||||
# закэширован в sys.modules к моменту первого теста). Прямая проверка исходника
|
# удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не
|
||||||
# — детерминированный, дешёвый и точный регрессионный гейт на саму строку,
|
# говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом
|
||||||
# которую правил review (#2457).
|
# before_send (локальные переменные кадра стека уходят мимо ключевого скраба).
|
||||||
|
#
|
||||||
|
# Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе
|
||||||
|
# (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него
|
||||||
|
# доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а
|
||||||
|
# `capture_envelope` подменён до первого события.
|
||||||
|
|
||||||
|
|
||||||
def test_main_wires_scrub_event_to_both_channels() -> None:
|
@lru_cache(maxsize=2)
|
||||||
"""app/main.py: before_send И before_send_transaction ОБА на scrub_event."""
|
def _probe(module: str) -> str:
|
||||||
text = (_BACKEND_ROOT / "app" / "main.py").read_text(encoding="utf-8")
|
"""Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку."""
|
||||||
assert "before_send=scrub_event" in text
|
env = {
|
||||||
assert "before_send_transaction=scrub_event" in text
|
**os.environ,
|
||||||
|
"TESTING": "1",
|
||||||
|
# Синтаксически валидный DSN на несуществующий хост: init отработает,
|
||||||
|
# сети не будет даже если транспорт когда-нибудь перестанут подменять.
|
||||||
|
"GLITCHTIP_DSN": "https://probe@localhost.invalid/1",
|
||||||
|
# Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/),
|
||||||
|
# и без этого `import app` уехал бы в editable-установку пакета — то есть
|
||||||
|
# проба мерила бы чужое дерево, а не то, что рядом с ней лежит.
|
||||||
|
"PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]),
|
||||||
|
}
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module],
|
||||||
|
cwd=_BACKEND_ROOT,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}"
|
||||||
|
return proc.stdout.strip().splitlines()[-1]
|
||||||
|
|
||||||
|
|
||||||
def test_celery_app_wires_scrub_event_to_both_channels() -> None:
|
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
|
||||||
"""app/workers/celery_app.py: before_send И before_send_transaction ОБА на
|
def test_pii_never_reaches_transport(module: str) -> None:
|
||||||
scrub_event (раньше before_send не было вообще)."""
|
"""Оба канала (error И transaction) отдают транспорту событие без ПДн.
|
||||||
text = (_BACKEND_ROOT / "app" / "workers" / "celery_app.py").read_text(encoding="utf-8")
|
|
||||||
assert "before_send=scrub_event" in text
|
Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ
|
||||||
assert "before_send_transaction=scrub_event" in text
|
`before_send_transaction` — комментарий с теми же словами не спасает.
|
||||||
|
"""
|
||||||
|
probe = json.loads(_probe(module))
|
||||||
|
markers = probe["markers"]
|
||||||
|
|
||||||
|
for channel in ("error", "transaction"):
|
||||||
|
payload = probe["payloads"][channel]
|
||||||
|
# Контроль «событие вообще доехало»: без него проверка была бы зелёной
|
||||||
|
# и на пробе, которая молча ничего не отправила.
|
||||||
|
assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало"
|
||||||
|
assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал"
|
||||||
|
|
||||||
|
leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload]
|
||||||
|
assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
|
||||||
|
def test_local_variables_never_reach_transport(module: str) -> None:
|
||||||
|
"""`include_local_variables=False`: значения локальных переменных кадра стека
|
||||||
|
не уходят в мониторинг (#2753).
|
||||||
|
|
||||||
|
Ключевой скраб такое не ловит по построению — имя переменной произвольно,
|
||||||
|
а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()`
|
||||||
|
(в sentry-sdk он по умолчанию `True`).
|
||||||
|
"""
|
||||||
|
probe = json.loads(_probe(module))
|
||||||
|
payload = probe["payloads"]["exception"]
|
||||||
|
|
||||||
|
assert probe["counts"]["exception"] == 1
|
||||||
|
assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
probe["markers"]["local_var"] not in payload
|
||||||
|
), f"{module}: значение локальной переменной ушло в мониторинг"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
|
||||||
|
def test_scrub_failure_does_not_spawn_second_event(module: str) -> None:
|
||||||
|
"""Сбой самого скраба не порождает ВТОРОГО события (#2753).
|
||||||
|
|
||||||
|
`logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об
|
||||||
|
отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR),
|
||||||
|
снова попала бы в скраб, снова упала — рекурсия, защиты от которой в SDK
|
||||||
|
нет (проверено на коде до фикса: не завершается). Красный, если
|
||||||
|
`ignore_logger` убрать: обработчик войдёт повторно.
|
||||||
|
"""
|
||||||
|
probe = json.loads(_probe(module))
|
||||||
|
|
||||||
|
assert (
|
||||||
|
probe["scrub_handler_entries"] == 1
|
||||||
|
), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг"
|
||||||
|
assert probe["counts"]["scrub_failure"] == 1
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
| `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) |
|
| `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) |
|
||||||
| `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** |
|
| `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** |
|
||||||
| `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** |
|
| `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** |
|
||||||
| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) |
|
| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — |
|
||||||
|
|
||||||
### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`)
|
### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`)
|
||||||
|
|
||||||
|
|
@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`):
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
|
## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
|
||||||
|
|
||||||
Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
|
Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
|
||||||
**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436
|
**Ротировать нечего: переменной больше нет.**
|
||||||
(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth.
|
|
||||||
Поле оставлено в `core/deps.py` только для быстрого rollback.
|
|
||||||
|
|
||||||
**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока
|
История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран
|
||||||
`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F**
|
со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и
|
||||||
(процедура: `sed` в `backend/.env.runtime` → `up -d --force-recreate --no-deps backend beat`,
|
dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до
|
||||||
downtime отсутствует). Фактический прод-прогон ротации — операционное действие
|
#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему
|
||||||
(Anton), не выполняется в рамках этого PR.
|
репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте —
|
||||||
|
в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно
|
||||||
|
означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть
|
||||||
|
хранение поля не экономило ничего. Удалено вместе с `core/deps.py`,
|
||||||
|
`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах.
|
||||||
|
|
||||||
|
Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**:
|
||||||
|
1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only`
|
||||||
|
для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста).
|
||||||
|
2. Caddy basic_auth на весь `gendsgn.ru` (PR #426).
|
||||||
|
|
||||||
|
Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно:
|
||||||
|
`Settings.model_config` — `extra="ignore"`, лишняя переменная безвредна.
|
||||||
|
|
||||||
`JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** —
|
`JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** —
|
||||||
добавить в реестр (класс **F**) при внедрении JWT-аутентификации.
|
добавить в реестр (класс **F**) при внедрении JWT-аутентификации.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
|
||||||
import { LandTab } from "@/components/site-finder/LandTab";
|
import { LandTab } from "@/components/site-finder/LandTab";
|
||||||
import { MarketTab } from "@/components/site-finder/MarketTab";
|
import { MarketTab } from "@/components/site-finder/MarketTab";
|
||||||
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
||||||
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||||
import { useCustomPois } from "@/hooks/useCustomPois";
|
import { useCustomPois } from "@/hooks/useCustomPois";
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
POI_DEFAULT_WEIGHTS,
|
POI_DEFAULT_WEIGHTS,
|
||||||
type PoiCategoryKey,
|
type PoiCategoryKey,
|
||||||
} from "@/lib/api/weightProfiles";
|
} from "@/lib/api/weightProfiles";
|
||||||
|
import { useMe } from "@/lib/useMe";
|
||||||
|
|
||||||
// SiteMap imports Leaflet which requires browser APIs — load without SSR
|
// SiteMap imports Leaflet which requires browser APIs — load without SSR
|
||||||
const SiteMap = dynamic(
|
const SiteMap = dynamic(
|
||||||
|
|
@ -129,16 +130,16 @@ function SiteFinderContent() {
|
||||||
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
|
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
|
||||||
const weightsChangeInitializedRef = useRef(false);
|
const weightsChangeInitializedRef = useRef(false);
|
||||||
|
|
||||||
const [profileUserId, setProfileUserId] = useState<string>(() =>
|
// Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения
|
||||||
typeof window === "undefined"
|
// из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`,
|
||||||
? ""
|
// который сервер не читает с #437) — оба недостижимы без DevTools.
|
||||||
: (localStorage.getItem("admin_user_id") ?? ""),
|
//
|
||||||
);
|
// 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без
|
||||||
const [adminToken] = useState<string>(() =>
|
// `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом
|
||||||
typeof window === "undefined"
|
// `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без
|
||||||
? ""
|
// user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль
|
||||||
: (localStorage.getItem("admin_token") ?? ""),
|
// молча не применялся бы, а UI показывал бы его ползунки.
|
||||||
);
|
const profileUserId = useMe().data?.username ?? "";
|
||||||
// Lazy init: считаем initialTab один раз на mount (useState всё равно
|
// Lazy init: считаем initialTab один раз на mount (useState всё равно
|
||||||
// игнорирует initializer после первого render — не тратим CPU).
|
// игнорирует initializer после первого render — не тратим CPU).
|
||||||
const [tab, setTabState] = useState<TabId>(() => {
|
const [tab, setTabState] = useState<TabId>(() => {
|
||||||
|
|
@ -192,12 +193,7 @@ function SiteFinderContent() {
|
||||||
setIsochrones(undefined);
|
setIsochrones(undefined);
|
||||||
mutate({
|
mutate({
|
||||||
cad: currentData.cad_num,
|
cad: currentData.cad_num,
|
||||||
options:
|
options: buildAnalyzeOptions(weights, profileId, currentProfileUserId),
|
||||||
profileId != null
|
|
||||||
? { profileId }
|
|
||||||
: currentProfileUserId
|
|
||||||
? { profileUserId: currentProfileUserId, weights }
|
|
||||||
: { weights },
|
|
||||||
});
|
});
|
||||||
// mutate is stable from useMutation — safe to omit from deps.
|
// mutate is stable from useMutation — safe to omit from deps.
|
||||||
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
|
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
|
||||||
|
|
@ -216,12 +212,11 @@ function SiteFinderContent() {
|
||||||
// slider values are always respected even without a saved profile (#201).
|
// slider values are always respected even without a saved profile (#201).
|
||||||
mutate({
|
mutate({
|
||||||
cad: cadNum,
|
cad: cadNum,
|
||||||
options:
|
options: buildAnalyzeOptions(
|
||||||
activeProfileId != null
|
currentWeights,
|
||||||
? { profileId: activeProfileId }
|
activeProfileId,
|
||||||
: profileUserId
|
profileUserId,
|
||||||
? { profileUserId, weights: currentWeights }
|
),
|
||||||
: { weights: currentWeights },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,46 +324,9 @@ function SiteFinderContent() {
|
||||||
|
|
||||||
{/* Weight profile panel — collapsible, below header */}
|
{/* Weight profile panel — collapsible, below header */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
|
|
||||||
{!!adminToken && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
User ID (для профилей):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={profileUserId}
|
|
||||||
placeholder="user-abc"
|
|
||||||
style={{
|
|
||||||
padding: "4px 8px",
|
|
||||||
fontSize: 12,
|
|
||||||
border: "1px solid #d1d5db",
|
|
||||||
borderRadius: 6,
|
|
||||||
width: 180,
|
|
||||||
}}
|
|
||||||
onChange={(e) => {
|
|
||||||
setProfileUserId(e.target.value);
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem("admin_user_id", e.target.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<WeightProfilePanel
|
<WeightProfilePanel
|
||||||
currentWeights={currentWeights}
|
currentWeights={currentWeights}
|
||||||
onWeightsChange={handleWeightsChange}
|
onWeightsChange={handleWeightsChange}
|
||||||
userId={profileUserId || undefined}
|
|
||||||
adminToken={adminToken || undefined}
|
|
||||||
/>
|
/>
|
||||||
{/* Recalculation indicator — shown while re-analyze is in-flight after
|
{/* Recalculation indicator — shown while re-analyze is in-flight after
|
||||||
weights change (data already loaded, pendingWeightsChange set). */}
|
weights change (data already loaded, pendingWeightsChange set). */}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
/* ===================== SCOPED TOKENS (dark) ===================== */
|
/* ===================== SCOPED TOKENS (dark) ===================== */
|
||||||
.pticaRoot[data-theme="dark"] {
|
.pticaRoot[data-theme="dark"] {
|
||||||
--font-ui:
|
--font-ui:
|
||||||
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI",
|
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui,
|
||||||
system-ui, sans-serif;
|
sans-serif;
|
||||||
--font-mono:
|
--font-mono:
|
||||||
var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace,
|
var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace,
|
||||||
monospace;
|
monospace;
|
||||||
|
|
@ -907,31 +907,6 @@
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== PLACEHOLDER PANEL ===================== */
|
|
||||||
.placeholderPanel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
text-align: center;
|
|
||||||
min-height: 240px;
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
.placeholderPanel .soon {
|
|
||||||
font-size: 22px;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.placeholderPanel p {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-soft);
|
|
||||||
margin: 0;
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================== STATE SCREENS ===================== */
|
/* ===================== STATE SCREENS ===================== */
|
||||||
.stateScreen {
|
.stateScreen {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -526,14 +526,15 @@ function VariantPanel({
|
||||||
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||||
: ""}
|
: ""}
|
||||||
НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК
|
НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК
|
||||||
РФ); НДС начисляется только на паркинг (нежилые машиноместа).
|
РФ); НДС начисляется на нежилое — паркинг (машиноместа) и
|
||||||
Входной НДС по строительству уже учтён в себестоимости. Налог на
|
коммерцию 1-го этажа. Входной НДС по строительству уже учтён в
|
||||||
прибыль — 25% (с 2025 года). Цена продажи жилья —{" "}
|
себестоимости. Налог на прибыль — 25% (с 2025 года). Цена продажи
|
||||||
|
жилья —{" "}
|
||||||
{financial.price_is_calibrated
|
{financial.price_is_calibrated
|
||||||
? `калибрована по рынку (${priceSourceCaption(financial)})`
|
? `калибрована по рынку (${priceSourceCaption(financial)})`
|
||||||
: "норматив класса (нет рыночных данных по участку)"}
|
: "норматив класса (нет рыночных данных по участку)"}
|
||||||
; себестоимость СМР и цена паркинга — нормативные ориентиры.
|
; себестоимость СМР, цена паркинга и цена нежилого — нормативные
|
||||||
Коммерческие и офисные площади не учитываются.
|
ориентиры.
|
||||||
{financial.financing_enabled && financial.financing_is_simplified
|
{financial.financing_enabled && financial.financing_is_simplified
|
||||||
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
|
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
|
||||||
"кредитом по ставке-нормативу, проценты капитализируются, " +
|
"кредитом по ставке-нормативу, проценты капитализируются, " +
|
||||||
|
|
@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
||||||
value={formatMoneyCompact(financial.gross_margin_rub)}
|
value={formatMoneyCompact(financial.gross_margin_rub)}
|
||||||
/>
|
/>
|
||||||
<CascadeRow
|
<CascadeRow
|
||||||
label="НДС (паркинг)"
|
label="НДС (паркинг + коммерция)"
|
||||||
value={formatMoneyCompact(financial.vat_rub)}
|
value={formatMoneyCompact(financial.vat_rub)}
|
||||||
/>
|
/>
|
||||||
<CascadeRow
|
<CascadeRow
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||||
|
import { useMe } from "@/lib/useMe";
|
||||||
import {
|
import {
|
||||||
POI_CATEGORIES,
|
POI_CATEGORIES,
|
||||||
POI_DEFAULT_WEIGHTS,
|
POI_DEFAULT_WEIGHTS,
|
||||||
|
|
@ -29,13 +30,6 @@ interface Props {
|
||||||
weights: Record<PoiCategoryKey, number>,
|
weights: Record<PoiCategoryKey, number>,
|
||||||
profileId: number | null,
|
profileId: number | null,
|
||||||
) => void;
|
) => void;
|
||||||
/**
|
|
||||||
* If provided, enables save/load from DB.
|
|
||||||
* Must be non-empty for CRUD functionality.
|
|
||||||
*/
|
|
||||||
userId?: string;
|
|
||||||
/** Admin token for CRUD API calls. */
|
|
||||||
adminToken?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -66,12 +60,7 @@ function weightsEqual(
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function WeightProfilePanel({
|
export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
||||||
currentWeights,
|
|
||||||
onWeightsChange,
|
|
||||||
userId,
|
|
||||||
adminToken,
|
|
||||||
}: Props) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Local draft weights — editable before "Применить"
|
// Local draft weights — editable before "Применить"
|
||||||
|
|
@ -89,11 +78,19 @@ export function WeightProfilePanel({
|
||||||
const [saveName, setSaveName] = useState("");
|
const [saveName, setSaveName] = useState("");
|
||||||
const [saveDefault, setSaveDefault] = useState(false);
|
const [saveDefault, setSaveDefault] = useState(false);
|
||||||
|
|
||||||
// Profiles query (only when userId + adminToken provided)
|
// Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили
|
||||||
const canUseCrud = !!userId && !!adminToken;
|
// руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было
|
||||||
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
|
// взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в
|
||||||
|
// #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут
|
||||||
|
// RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется).
|
||||||
|
// В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто
|
||||||
|
// бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен.
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const userId = me?.username ?? "";
|
||||||
|
const canUseCrud = !!userId;
|
||||||
|
const profilesQuery = useWeightProfiles(userId);
|
||||||
|
|
||||||
const createMutation = useCreateProfile(adminToken ?? "");
|
const createMutation = useCreateProfile();
|
||||||
|
|
||||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -117,7 +114,7 @@ export function WeightProfilePanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveProfile = useCallback(async () => {
|
const handleSaveProfile = useCallback(async () => {
|
||||||
if (!canUseCrud || !userId || !adminToken) {
|
if (!canUseCrud || !userId) {
|
||||||
setShowSaveDialog(false);
|
setShowSaveDialog(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -136,15 +133,7 @@ export function WeightProfilePanel({
|
||||||
} catch {
|
} catch {
|
||||||
// Error visible through createMutation.error
|
// Error visible through createMutation.error
|
||||||
}
|
}
|
||||||
}, [
|
}, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]);
|
||||||
canUseCrud,
|
|
||||||
userId,
|
|
||||||
adminToken,
|
|
||||||
saveName,
|
|
||||||
draft,
|
|
||||||
saveDefault,
|
|
||||||
createMutation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -242,6 +231,7 @@ export function WeightProfilePanel({
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<label
|
<label
|
||||||
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
||||||
|
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
|
||||||
>
|
>
|
||||||
Профиль:
|
Профиль:
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -278,10 +268,13 @@ export function WeightProfilePanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hint when no crud */}
|
{/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
|
||||||
|
Caddy basic_auth) — остаётся для dev-запуска без прокси, где
|
||||||
|
/api/v1/me отвечает 401. Текст называет причину, а не действие:
|
||||||
|
вводить тут больше нечего. */}
|
||||||
{!canUseCrud && (
|
{!canUseCrud && (
|
||||||
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
|
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
|
||||||
Укажите User ID и Admin Token для сохранения профилей.
|
Пользователь не определён — сохранение профилей недоступно.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -362,6 +355,9 @@ export function WeightProfilePanel({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SectionLabel>Новый профиль</SectionLabel>
|
<SectionLabel>Новый профиль</SectionLabel>
|
||||||
|
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
|
||||||
|
Сохранится для пользователя {userId} — другие его не увидят.
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* #2782 — CRUD профилей весов был недостижим никому без DevTools.
|
||||||
|
*
|
||||||
|
* Панель отпирала сохранение/загрузку профилей значением `admin_token` из
|
||||||
|
* localStorage. Записывать его было нечем: инпут удалили в #442. Сервер этот
|
||||||
|
* заголовок к тому моменту уже не читал (#437, остатки убраны в #2775) — то есть
|
||||||
|
* фича стояла за признаком, который ничего не решал.
|
||||||
|
*
|
||||||
|
* Тесты ниже пиннят три вещи, каждая из которых на старом коде красная:
|
||||||
|
* 1) панель берёт владельца профилей из /api/v1/me и включает CRUD без пропсов;
|
||||||
|
* 2) в запросы CRUD не уходит X-Admin-Token;
|
||||||
|
* 3) `profileId` не уходит в /analyze без `profileUserId` (иначе бэкенд молча
|
||||||
|
* считает по системным весам, рапортуя source="profile").
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { WeightProfilePanel } from "../WeightProfilePanel";
|
||||||
|
import { buildAnalyzeOptions } from "@/hooks/useSiteAnalysis";
|
||||||
|
import { POI_DEFAULT_WEIGHTS } from "@/lib/api/weightProfiles";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE = {
|
||||||
|
id: 1,
|
||||||
|
user_id: "admin",
|
||||||
|
profile_name: "Мой профиль",
|
||||||
|
weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 },
|
||||||
|
is_default: true,
|
||||||
|
description: null,
|
||||||
|
created_at: "2026-05-15T05:30:51Z",
|
||||||
|
updated_at: "2026-05-15T05:30:51Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<WeightProfilePanel
|
||||||
|
currentWeights={{ ...POI_DEFAULT_WEIGHTS }}
|
||||||
|
onWeightsChange={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
|
||||||
|
async function openPanel() {
|
||||||
|
const { default: userEvent } = await import("@testing-library/user-event");
|
||||||
|
await userEvent.setup().click(screen.getByText("POI Веса"));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
fetchMock.mockImplementation(async (input) => {
|
||||||
|
const url = typeof input === "string" ? input : String(input);
|
||||||
|
if (url.includes("/api/v1/me")) {
|
||||||
|
return jsonResponse({
|
||||||
|
username: "admin",
|
||||||
|
role: "admin",
|
||||||
|
allowed_paths: ["/**"],
|
||||||
|
deny_paths: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/weight-profiles")) {
|
||||||
|
return jsonResponse([PROFILE]);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => {
|
||||||
|
it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await openPanel();
|
||||||
|
|
||||||
|
// Профили грузятся по user_id из /me, без ручного ввода и без токена.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Профиль:")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await openPanel();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/Admin Token/i)).toBeNull();
|
||||||
|
expect(screen.queryByText(/Укажите User ID/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await openPanel();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const crudCall = fetchMock.mock.calls.find(([input]) =>
|
||||||
|
String(input).includes("/weight-profiles"),
|
||||||
|
);
|
||||||
|
expect(crudCall).toBeTruthy();
|
||||||
|
const headers = new Headers(
|
||||||
|
(crudCall?.[1] as RequestInit | undefined)?.headers,
|
||||||
|
);
|
||||||
|
expect(headers.has("X-Admin-Token")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => {
|
||||||
|
const weights = { ...POI_DEFAULT_WEIGHTS };
|
||||||
|
|
||||||
|
it("выбранный профиль уходит вместе с profileUserId", () => {
|
||||||
|
expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({
|
||||||
|
profileId: 7,
|
||||||
|
profileUserId: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => {
|
||||||
|
const options = buildAnalyzeOptions(weights, 7, "");
|
||||||
|
expect(options.profileId).toBeUndefined();
|
||||||
|
expect(options.weights).toEqual(weights);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без профиля уходят inline-веса и владелец для default-профиля", () => {
|
||||||
|
expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({
|
||||||
|
profileUserId: "admin",
|
||||||
|
weights,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
// /fetch-status every 2s, re-triggers analyze когда status=ready.
|
// /fetch-status every 2s, re-triggers analyze когда status=ready.
|
||||||
|
|
||||||
export type FetchStatus =
|
export type FetchStatus =
|
||||||
| "ready"
|
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
|
||||||
| "fetching"
|
|
||||||
| "failed"
|
|
||||||
| "not_in_nspd"
|
|
||||||
| "invalid_format";
|
|
||||||
|
|
||||||
export interface FetchStatusResponse {
|
export interface FetchStatusResponse {
|
||||||
status: FetchStatus;
|
status: FetchStatus;
|
||||||
|
|
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
|
||||||
weights?: Record<string, number> | null;
|
weights?: Record<string, number> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать options для POST /analyze из состояния панели весов POI.
|
||||||
|
*
|
||||||
|
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
|
||||||
|
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
|
||||||
|
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
|
||||||
|
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
|
||||||
|
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
|
||||||
|
* #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из
|
||||||
|
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
|
||||||
|
*
|
||||||
|
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline
|
||||||
|
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
|
||||||
|
*/
|
||||||
|
export function buildAnalyzeOptions(
|
||||||
|
weights: Record<string, number>,
|
||||||
|
profileId: number | null,
|
||||||
|
profileUserId: string,
|
||||||
|
): AnalyzeOptions {
|
||||||
|
if (profileId != null && profileUserId) {
|
||||||
|
return { profileId, profileUserId };
|
||||||
|
}
|
||||||
|
if (profileUserId) {
|
||||||
|
return { profileUserId, weights };
|
||||||
|
}
|
||||||
|
return { weights };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook для analyze flow с graceful on-demand fetch fallback.
|
* Custom hook для analyze flow с graceful on-demand fetch fallback.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -91,35 +91,37 @@ export const POI_WEIGHT_MAX = 3;
|
||||||
|
|
||||||
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
||||||
|
|
||||||
function profilesHeaders(adminToken: string): HeadersInit {
|
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
|
||||||
return { "X-Admin-Token": adminToken };
|
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
|
||||||
}
|
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
|
||||||
|
// с корректным токеном, с мусорным и без заголовка вовсе.
|
||||||
|
//
|
||||||
|
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
|
||||||
|
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
|
||||||
|
// (подставленный клиентом X-Authenticated-User туда же не проходит);
|
||||||
|
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
|
||||||
|
|
||||||
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** List all weight profiles for a given user_id. */
|
/** List all weight profiles for a given user_id. */
|
||||||
export function useWeightProfiles(userId: string, adminToken: string) {
|
export function useWeightProfiles(userId: string) {
|
||||||
return useQuery<WeightProfile[]>({
|
return useQuery<WeightProfile[]>({
|
||||||
queryKey: ["weight-profiles", userId],
|
queryKey: ["weight-profiles", userId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<WeightProfile[]>(
|
apiFetch<WeightProfile[]>(
|
||||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
||||||
{
|
|
||||||
headers: profilesHeaders(adminToken),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
enabled: !!userId && !!adminToken,
|
enabled: !!userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new weight profile. Invalidates the list query on success. */
|
/** Create a new weight profile. Invalidates the list query on success. */
|
||||||
export function useCreateProfile(adminToken: string) {
|
export function useCreateProfile() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<WeightProfile, Error, WeightProfileCreate>({
|
return useMutation<WeightProfile, Error, WeightProfileCreate>({
|
||||||
mutationFn: (payload) =>
|
mutationFn: (payload) =>
|
||||||
apiFetch<WeightProfile>(BASE_PATH, {
|
apiFetch<WeightProfile>(BASE_PATH, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: profilesHeaders(adminToken),
|
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
|
|
@ -131,11 +133,7 @@ export function useCreateProfile(adminToken: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update an existing weight profile by id. */
|
/** Update an existing weight profile by id. */
|
||||||
export function useUpdateProfile(
|
export function useUpdateProfile(userId: string, profileId: number) {
|
||||||
userId: string,
|
|
||||||
profileId: number,
|
|
||||||
adminToken: string,
|
|
||||||
) {
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
||||||
mutationFn: (payload) =>
|
mutationFn: (payload) =>
|
||||||
|
|
@ -143,7 +141,6 @@ export function useUpdateProfile(
|
||||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: profilesHeaders(adminToken),
|
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -154,16 +151,13 @@ export function useUpdateProfile(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
||||||
export function useDeleteProfile(userId: string, adminToken: string) {
|
export function useDeleteProfile(userId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<void, Error, number>({
|
return useMutation<void, Error, number>({
|
||||||
mutationFn: (profileId) =>
|
mutationFn: (profileId) =>
|
||||||
apiFetch<void>(
|
apiFetch<void>(
|
||||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||||
{
|
{ method: "DELETE" },
|
||||||
method: "DELETE",
|
|
||||||
headers: profilesHeaders(adminToken),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||||
|
|
|
||||||
242
scripts/check-migration-lock-timeout.py
Normal file
242
scripts/check-migration-lock-timeout.py
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752).
|
||||||
|
|
||||||
|
ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала
|
||||||
|
на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за
|
||||||
|
чужой аналитической psql-сессией; вторая попытка деплоя — ещё 16 минут. Опасность
|
||||||
|
не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
|
||||||
|
запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за
|
||||||
|
ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих
|
||||||
|
data/sql — то есть следующий блокирующий DDL повторил бы это.
|
||||||
|
|
||||||
|
`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный
|
||||||
|
CREATE INDEX он не оборвёт, а очередь — не соберёт. Срабатывание = красный деплой
|
||||||
|
(ON_ERROR_STOP=on) вместо тихой очереди перед приложением.
|
||||||
|
|
||||||
|
ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант
|
||||||
|
«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4:
|
||||||
|
`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW
|
||||||
|
lock_timeout` → 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX
|
||||||
|
CONCURRENTLY` — тот ждёт завершения параллельных транзакций через VirtualXactLock,
|
||||||
|
и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная
|
||||||
|
сессия просто держала открытую транзакцию (ACCESS SHARE — с CIC вообще не
|
||||||
|
конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение
|
||||||
|
изготавливало бы ровно ту аварию, от которой заведена проверка невалидных
|
||||||
|
индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят
|
||||||
|
противоположной политики, поэтому granularity — файл, а не раннер.
|
||||||
|
|
||||||
|
ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца
|
||||||
|
сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL`
|
||||||
|
молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента.
|
||||||
|
|
||||||
|
ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы
|
||||||
|
задним числом не переписываются. Гейт смотрит только вперёд.
|
||||||
|
|
||||||
|
Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# каталог миграций -> минимальный NN, с которого правило обязательно.
|
||||||
|
# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая).
|
||||||
|
SQL_DIRS: dict[str, int] = {
|
||||||
|
"data/sql": 189,
|
||||||
|
"tradein-mvp/backend/data/sql": 250,
|
||||||
|
}
|
||||||
|
|
||||||
|
# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE,
|
||||||
|
# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать
|
||||||
|
# в очередь и увести за собой запросы приложения.
|
||||||
|
BLOCKING = re.compile(
|
||||||
|
r"\b(?:"
|
||||||
|
r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|"
|
||||||
|
r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|"
|
||||||
|
r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|"
|
||||||
|
r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL"
|
||||||
|
r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку).
|
||||||
|
# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE
|
||||||
|
# по-прежнему обязан прикрыть свой ALTER.
|
||||||
|
CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE)
|
||||||
|
SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE)
|
||||||
|
SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE)
|
||||||
|
NN_PREFIX = re.compile(r"^(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_noise(sql: str) -> str:
|
||||||
|
"""Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на
|
||||||
|
пробелы (сохраняя длину и переводы строк — номера строк не съезжают).
|
||||||
|
|
||||||
|
Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...'
|
||||||
|
легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу.
|
||||||
|
Тела $$...$$ (DO-блоки) НЕ затираются — там живёт исполняемый DDL.
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
i, n = 0, len(sql)
|
||||||
|
while i < n:
|
||||||
|
ch = sql[i]
|
||||||
|
nxt = sql[i + 1] if i + 1 < n else ""
|
||||||
|
if ch == "-" and nxt == "-":
|
||||||
|
while i < n and sql[i] != "\n":
|
||||||
|
out.append(" ")
|
||||||
|
i += 1
|
||||||
|
elif ch == "/" and nxt == "*":
|
||||||
|
depth = 1 # в PostgreSQL блочные комментарии вложенные
|
||||||
|
out.append(" ")
|
||||||
|
i += 2
|
||||||
|
while i < n and depth:
|
||||||
|
if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*":
|
||||||
|
depth += 1
|
||||||
|
out.append(" ")
|
||||||
|
i += 2
|
||||||
|
elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/":
|
||||||
|
depth -= 1
|
||||||
|
out.append(" ")
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
out.append("\n" if sql[i] == "\n" else " ")
|
||||||
|
i += 1
|
||||||
|
elif ch == "'":
|
||||||
|
out.append("'")
|
||||||
|
i += 1
|
||||||
|
while i < n:
|
||||||
|
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
|
||||||
|
out.append(" ")
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if sql[i] == "'":
|
||||||
|
break
|
||||||
|
out.append("\n" if sql[i] == "\n" else " ")
|
||||||
|
i += 1
|
||||||
|
if i < n:
|
||||||
|
out.append("'")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
i += 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def scan(sql: str) -> list[str]:
|
||||||
|
"""-> список претензий к файлу; пустой список = файл в порядке."""
|
||||||
|
clean = strip_noise(sql)
|
||||||
|
statements = clean.split(";")
|
||||||
|
|
||||||
|
first_blocking: int | None = None
|
||||||
|
blocking_text = ""
|
||||||
|
for idx, stmt in enumerate(statements):
|
||||||
|
if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt):
|
||||||
|
first_blocking = idx
|
||||||
|
blocking_text = " ".join(stmt.split())[:80]
|
||||||
|
break
|
||||||
|
if first_blocking is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None)
|
||||||
|
if set_local is None:
|
||||||
|
if any(SET_BARE_LT.search(s) for s in statements):
|
||||||
|
return [
|
||||||
|
f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). "
|
||||||
|
"Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY "
|
||||||
|
"в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN."
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой "
|
||||||
|
"строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет "
|
||||||
|
"в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)."
|
||||||
|
]
|
||||||
|
|
||||||
|
problems: list[str] = []
|
||||||
|
if not any(BEGIN_STMT.search(s) for s in statements[:set_local]):
|
||||||
|
problems.append(
|
||||||
|
"`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). "
|
||||||
|
"Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)."
|
||||||
|
)
|
||||||
|
if set_local > first_blocking:
|
||||||
|
problems.append(
|
||||||
|
f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — "
|
||||||
|
"к моменту DDL он ещё не действует. Подними его сразу под BEGIN."
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
|
||||||
|
assert scan(ok) == [], scan(ok)
|
||||||
|
|
||||||
|
# красное: ровно случай 250 до фикса
|
||||||
|
bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
|
||||||
|
assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0]
|
||||||
|
assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n")
|
||||||
|
assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n")
|
||||||
|
assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n")
|
||||||
|
assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n")
|
||||||
|
|
||||||
|
# красное: правильная строка, но в местах, где она не действует
|
||||||
|
assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0]
|
||||||
|
late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n"
|
||||||
|
assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late))
|
||||||
|
bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||||
|
assert "без LOCAL" in scan(bare)[0]
|
||||||
|
|
||||||
|
# зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC)
|
||||||
|
assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == []
|
||||||
|
assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == []
|
||||||
|
assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == []
|
||||||
|
# ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL
|
||||||
|
mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||||
|
assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле"
|
||||||
|
mixed_ok = (
|
||||||
|
"CREATE INDEX CONCURRENTLY i ON t (c);\n"
|
||||||
|
"BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||||
|
)
|
||||||
|
assert scan(mixed_ok) == [], scan(mixed_ok)
|
||||||
|
|
||||||
|
# зелёное: DDL, которого нет — он в комментарии или в строковом литерале
|
||||||
|
assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == []
|
||||||
|
assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == []
|
||||||
|
assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == []
|
||||||
|
assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == []
|
||||||
|
assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == []
|
||||||
|
# зелёное: не-DDL миграции (backfill/seed) правила не касаются
|
||||||
|
assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == []
|
||||||
|
assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == []
|
||||||
|
print("selftest OK")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if "--selftest" in sys.argv:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
failed = False
|
||||||
|
checked = 0
|
||||||
|
for dirname, min_nn in SQL_DIRS.items():
|
||||||
|
sql_dir = Path(dirname)
|
||||||
|
if not sql_dir.is_dir():
|
||||||
|
print(f"::error::{sql_dir} не найден — запускать из корня репозитория")
|
||||||
|
return 1
|
||||||
|
for path in sorted(sql_dir.glob("*.sql")):
|
||||||
|
m = NN_PREFIX.match(path.name)
|
||||||
|
if not m or int(m.group(1)) < min_nn:
|
||||||
|
continue # применено на проде до внедрения гейта — не переписываем
|
||||||
|
checked += 1
|
||||||
|
for problem in scan(path.read_text(encoding="utf-8")):
|
||||||
|
failed = True
|
||||||
|
print(f"::error file={path}::{problem}")
|
||||||
|
if failed:
|
||||||
|
return 1
|
||||||
|
print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1953,7 +1953,9 @@ async def scrape_cian_newbuilding(
|
||||||
save_newbuilding_enrichment,
|
save_newbuilding_enrichment,
|
||||||
)
|
)
|
||||||
|
|
||||||
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
|
enrichment = await fetch_newbuilding(
|
||||||
|
zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
|
||||||
|
)
|
||||||
if enrichment is None:
|
if enrichment is None:
|
||||||
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
|
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,27 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404,
|
||||||
|
# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись
|
||||||
|
# по коду ответа; третий потребитель (`/r/<token>`, PR-9) разошёлся бы
|
||||||
|
# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false
|
||||||
|
# в SQL — для всех существующих строк (retain_until IS NULL) поведение не
|
||||||
|
# меняется вообще. Не копировать это выражение по месту — только через
|
||||||
|
# константу/хелпер ниже. Payments retention, PR #2754.
|
||||||
|
ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())"
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool:
|
||||||
|
"""Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД.
|
||||||
|
|
||||||
|
tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf) —
|
||||||
|
`.replace(tzinfo=UTC)`, не переизобретается.
|
||||||
|
"""
|
||||||
|
now = datetime.now(tz=UTC)
|
||||||
|
if expires_at.replace(tzinfo=UTC) > now:
|
||||||
|
return True
|
||||||
|
return retain_until is not None and retain_until.replace(tzinfo=UTC) > now
|
||||||
|
|
||||||
|
|
||||||
def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None:
|
def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None:
|
||||||
"""IDOR guard (#690): только владелец оценки или admin могут её читать.
|
"""IDOR guard (#690): только владелец оценки или admin могут её читать.
|
||||||
|
|
@ -249,11 +270,11 @@ def get_estimate(
|
||||||
"""
|
"""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||||||
confidence, confidence_explanation, n_analogs,
|
confidence, confidence_explanation, n_analogs,
|
||||||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||||||
expires_at, address, lat, lon,
|
expires_at, retain_until, address, lat, lon,
|
||||||
area_m2, rooms, floor, total_floors,
|
area_m2, rooms, floor, total_floors,
|
||||||
year_built, house_type, repair_state, has_balcony,
|
year_built, house_type, repair_state, has_balcony,
|
||||||
canonical_address, house_cadnum, house_fias_id,
|
canonical_address, house_cadnum, house_fias_id,
|
||||||
|
|
@ -263,7 +284,7 @@ def get_estimate(
|
||||||
asking_to_sold_ratio, ratio_basis, created_by, created_at
|
asking_to_sold_ratio, ratio_basis, created_by, created_at
|
||||||
FROM trade_in_estimates
|
FROM trade_in_estimates
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
AND expires_at > NOW()
|
AND {ESTIMATE_READABLE_SQL}
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"id": str(estimate_id)},
|
{"id": str(estimate_id)},
|
||||||
|
|
@ -372,6 +393,7 @@ def get_estimate(
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
actual_deals=actual_deals,
|
actual_deals=actual_deals,
|
||||||
expires_at=row.expires_at,
|
expires_at=row.expires_at,
|
||||||
|
retain_until=row.retain_until,
|
||||||
target_address=row.address,
|
target_address=row.address,
|
||||||
target_lat=row.lat,
|
target_lat=row.lat,
|
||||||
target_lon=row.lon,
|
target_lon=row.lon,
|
||||||
|
|
@ -433,7 +455,7 @@ def estimate_pdf(
|
||||||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||||||
confidence, confidence_explanation, n_analogs,
|
confidence, confidence_explanation, n_analogs,
|
||||||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||||||
expires_at,
|
expires_at, retain_until,
|
||||||
address, lat, lon, area_m2, rooms, floor, total_floors,
|
address, lat, lon, area_m2, rooms, floor, total_floors,
|
||||||
year_built, house_type, repair_state, has_balcony,
|
year_built, house_type, repair_state, has_balcony,
|
||||||
canonical_address, house_cadnum, house_fias_id,
|
canonical_address, house_cadnum, house_fias_id,
|
||||||
|
|
@ -453,8 +475,12 @@ def estimate_pdf(
|
||||||
|
|
||||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
_assert_estimate_access(row.created_by, x_authenticated_user)
|
||||||
|
|
||||||
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
|
# PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше
|
||||||
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
# здесь была независимая Python-проверка expires_at, разошедшаяся с SQL-
|
||||||
|
# фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при
|
||||||
|
# годовом retain_until упоминание 24ч в ответе API стало бы ложью.
|
||||||
|
if not estimate_readable(row.expires_at, row.retain_until):
|
||||||
|
raise HTTPException(status_code=410, detail="estimate expired")
|
||||||
|
|
||||||
from app.services.estimator import _qc_geo_to_precision
|
from app.services.estimator import _qc_geo_to_precision
|
||||||
|
|
||||||
|
|
@ -477,6 +503,7 @@ def estimate_pdf(
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
actual_deals=actual_deals,
|
actual_deals=actual_deals,
|
||||||
expires_at=row.expires_at,
|
expires_at=row.expires_at,
|
||||||
|
retain_until=row.retain_until,
|
||||||
target_address=row.address,
|
target_address=row.address,
|
||||||
target_lat=row.lat,
|
target_lat=row.lat,
|
||||||
target_lon=row.lon,
|
target_lon=row.lon,
|
||||||
|
|
|
||||||
|
|
@ -836,6 +836,22 @@ class Settings(BaseSettings):
|
||||||
# срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS.
|
# срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS.
|
||||||
trade_in_lead_retention_days: int = 180
|
trade_in_lead_retention_days: int = 180
|
||||||
|
|
||||||
|
# ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ─────
|
||||||
|
# trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/
|
||||||
|
# СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность
|
||||||
|
# расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см.
|
||||||
|
# migration 240 докстринг. Отдельная колонка, а не подъём expires_at:
|
||||||
|
# expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков
|
||||||
|
# для всех строк, поднять его до года = соврать в документе клиента про
|
||||||
|
# свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов.
|
||||||
|
# Единственный источник числа «12 месяцев» на фронте —
|
||||||
|
# `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты,
|
||||||
|
# экран после оплаты и SQL продления retain_until при оплате (платёжный
|
||||||
|
# код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе
|
||||||
|
# классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране
|
||||||
|
# «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS.
|
||||||
|
trade_in_paid_retention_days: int = 365
|
||||||
|
|
||||||
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
|
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
|
||||||
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
|
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
|
||||||
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —
|
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,10 @@ class AggregatedEstimate(BaseModel):
|
||||||
analogs: list[AnalogLot]
|
analogs: list[AnalogLot]
|
||||||
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
|
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
|
# PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности
|
||||||
|
# расчёта — тот остаётся expires_at (не путать, см. migration 240).
|
||||||
|
# NULL = неоплачено (весь текущий трафик, B2B pilots включительно).
|
||||||
|
retain_until: datetime | None = None
|
||||||
# ── Дополнительные метаданные ──
|
# ── Дополнительные метаданные ──
|
||||||
target_address: str | None = None # geocoded full address
|
target_address: str | None = None # geocoded full address
|
||||||
target_lat: float | None = None
|
target_lat: float | None = None
|
||||||
|
|
|
||||||
|
|
@ -1050,6 +1050,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
)
|
)
|
||||||
report_num = _report_number(estimate)
|
report_num = _report_number(estimate)
|
||||||
|
|
||||||
|
# PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа
|
||||||
|
# (retain_until), НЕ путать со «Срок действия данных» (expires_at,
|
||||||
|
# актуальность расчёта) над ней — эта строка не трогается. Рендерится
|
||||||
|
# ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий
|
||||||
|
# трафик — не видят этой строки вообще, поведение бит-в-бит текущее).
|
||||||
|
retain_until_row = (
|
||||||
|
f'<tr><td class="dotted-row">Ссылка доступна до</td>'
|
||||||
|
f'<td class="bold dotted-row">'
|
||||||
|
f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}</td></tr>"
|
||||||
|
if estimate.retain_until is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
# Короткий адрес (для cover): берём первую часть до запятой
|
# Короткий адрес (для cover): берём первую часть до запятой
|
||||||
full_address = input_snapshot.get("address", "—")
|
full_address = input_snapshot.get("address", "—")
|
||||||
address_short = full_address.split(",")[0:3]
|
address_short = full_address.split(",")[0:3]
|
||||||
|
|
@ -1146,6 +1159,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
<td class="bold dotted-row">{_mono(today.strftime("%d.%m.%Y"))}</td></tr>
|
<td class="bold dotted-row">{_mono(today.strftime("%d.%m.%Y"))}</td></tr>
|
||||||
<tr><td class="dotted-row">Срок действия данных</td>
|
<tr><td class="dotted-row">Срок действия данных</td>
|
||||||
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
||||||
|
{retain_until_row}
|
||||||
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
||||||
<tr><td class="dotted-row">Год постройки</td>
|
<tr><td class="dotted-row">Год постройки</td>
|
||||||
<td class="bold dotted-row">{year_label}</td></tr>
|
<td class="bold dotted-row">{year_label}</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,7 @@ async def _job_deactivate_stale(
|
||||||
from app.core.config import settings as _settings
|
from app.core.config import settings as _settings
|
||||||
from app.tasks.deactivate_stale_avito import (
|
from app.tasks.deactivate_stale_avito import (
|
||||||
DEFAULT_MIN_CONFIRMATIONS,
|
DEFAULT_MIN_CONFIRMATIONS,
|
||||||
|
DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||||
deactivate_stale_listings,
|
deactivate_stale_listings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -229,6 +230,12 @@ async def _job_deactivate_stale(
|
||||||
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
||||||
# источнику пороги приходят из default_params (миграция 219).
|
# источнику пороги приходят из default_params (миграция 219).
|
||||||
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
|
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
|
||||||
|
# Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию:
|
||||||
|
# незасеянное расписание не должно снимать объявления по порогу ниже собственного
|
||||||
|
# хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0.
|
||||||
|
revisit_floor_quantile: float = params.get(
|
||||||
|
"revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
|
||||||
|
)
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
|
|
@ -241,6 +248,7 @@ async def _job_deactivate_stale(
|
||||||
segments=segments,
|
segments=segments,
|
||||||
staleness_column=staleness_column,
|
staleness_column=staleness_column,
|
||||||
min_confirmations=min_confirmations,
|
min_confirmations=min_confirmations,
|
||||||
|
revisit_floor_quantile=revisit_floor_quantile,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
|
||||||
«Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой
|
«Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой
|
||||||
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
|
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
|
||||||
|
|
||||||
|
Проба на ПАРУ «узел × источник» (#2800, продолжение #2723):
|
||||||
|
- #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом
|
||||||
|
для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв»
|
||||||
|
вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500
|
||||||
|
NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и
|
||||||
|
свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен».
|
||||||
|
- Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может
|
||||||
|
достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки
|
||||||
|
(apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а
|
||||||
|
`bff-search-web.domclick.ru`, куда ходит сбор, — 500).
|
||||||
|
- Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой
|
||||||
|
сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый
|
||||||
|
отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки.
|
||||||
|
Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt
|
||||||
|
площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать
|
||||||
|
дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»).
|
||||||
|
- Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение
|
||||||
|
«браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО
|
||||||
|
креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла.
|
||||||
|
Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками
|
||||||
|
вернули то самое схлопывание диагнозов.
|
||||||
|
|
||||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -125,6 +147,7 @@ __all__ = [
|
||||||
"mark_banned",
|
"mark_banned",
|
||||||
"mark_browser_health",
|
"mark_browser_health",
|
||||||
"mark_health",
|
"mark_health",
|
||||||
|
"mark_source_probe",
|
||||||
"reap_stale_leases",
|
"reap_stale_leases",
|
||||||
"release",
|
"release",
|
||||||
"run_proxy_healthcheck",
|
"run_proxy_healthcheck",
|
||||||
|
|
@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360
|
||||||
# намеренно не обновляется (см. mark_browser_health).
|
# намеренно не обновляется (см. mark_browser_health).
|
||||||
BROWSER_UNFIT_THRESHOLD = 2
|
BROWSER_UNFIT_THRESHOLD = 2
|
||||||
|
|
||||||
|
# ── проба на пару «узел × источник» (#2800) ──────────────────────────────────
|
||||||
|
# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт):
|
||||||
|
# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито;
|
||||||
|
# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть
|
||||||
|
# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch;
|
||||||
|
# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов
|
||||||
|
# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at).
|
||||||
|
# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при
|
||||||
|
# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было.
|
||||||
|
# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и
|
||||||
|
# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором
|
||||||
|
# acquire снова выдаёт узел, не спросив.
|
||||||
|
#
|
||||||
|
# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её
|
||||||
|
# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная
|
||||||
|
# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка
|
||||||
|
# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка
|
||||||
|
# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре.
|
||||||
|
_PROBE_BAN_REASON = "probe:browser"
|
||||||
|
|
||||||
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
|
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
|
||||||
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
|
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
|
||||||
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
|
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
|
||||||
|
|
@ -228,14 +271,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
|
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
|
||||||
чужой affinity (#2600).
|
чужой affinity (#2600).
|
||||||
|
|
||||||
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см.
|
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
|
||||||
173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1),
|
fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже,
|
||||||
намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого
|
чем голодание исходного источника, которое фикс призван устранить. Кандидат
|
||||||
одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex,
|
участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
|
||||||
domclick останется без прокси вообще — хуже, чем голодание исходного источника,
|
enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной
|
||||||
которое фикс призван устранить. Кандидат участвует в fallback, только если его
|
affinity целиком.
|
||||||
affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) —
|
|
||||||
т.е. выдача не обнулит доступность выделенной affinity целиком.
|
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql —
|
||||||
|
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
|
||||||
|
банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до
|
||||||
|
рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а
|
||||||
|
Авито/Яндекс через него работают — резервация держала узел за источником, которому
|
||||||
|
он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде
|
||||||
|
сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и
|
||||||
|
следующий выделенный узел должен получить её сразу, а не после повторного разбора.
|
||||||
|
|
||||||
ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у
|
ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у
|
||||||
(scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито,
|
(scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито,
|
||||||
|
|
@ -675,9 +725,43 @@ def mark_browser_health(
|
||||||
return "fail"
|
return "fail"
|
||||||
|
|
||||||
|
|
||||||
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str:
|
||||||
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
|
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
|
||||||
|
|
||||||
|
Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары
|
||||||
|
принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего
|
||||||
|
узла) | "missing" (нет такого proxy_id).
|
||||||
|
|
||||||
|
`reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
|
||||||
|
умолчанию 'banned:<source>' (бан распознан боевым сбором), у браузерной пробы —
|
||||||
|
_PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
|
||||||
|
clear_source_bans умеет фильтровать по ней (`only_reason`).
|
||||||
|
|
||||||
|
ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026:
|
||||||
|
пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через
|
||||||
|
ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр
|
||||||
|
«снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ:
|
||||||
|
присвоенная строка становится «своей», и следующая успешная проба снимает ею бан,
|
||||||
|
который поставил боевой сбор по настоящему отказу площадки. Плюс теряется
|
||||||
|
происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба
|
||||||
|
не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count
|
||||||
|
начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых
|
||||||
|
пары с 6 ч до 12 ч).
|
||||||
|
|
||||||
|
Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет),
|
||||||
|
ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`).
|
||||||
|
Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно»
|
||||||
|
только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен
|
||||||
|
УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего.
|
||||||
|
|
||||||
|
АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее
|
||||||
|
(площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится
|
||||||
|
ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный
|
||||||
|
robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально
|
||||||
|
и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного);
|
||||||
|
обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об
|
||||||
|
эскалации боевых банов пары.
|
||||||
|
|
||||||
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
|
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
|
||||||
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
|
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
|
||||||
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
|
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
|
||||||
|
|
@ -736,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||||
сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает
|
сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает
|
||||||
(падает как обычно) — caller решает, ловить или нет.
|
(падает как обычно) — caller решает, ловить или нет.
|
||||||
"""
|
"""
|
||||||
|
# Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё
|
||||||
|
# (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ").
|
||||||
|
live_reason = f"banned:{source}"
|
||||||
|
effective_reason = reason or live_reason
|
||||||
# Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг
|
# Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг
|
||||||
# "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции.
|
# "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции.
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -807,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||||
) AS integer)),
|
) AS integer)),
|
||||||
reason = CAST(:reason AS text),
|
reason = CAST(:reason AS text),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
|
-- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
|
||||||
|
-- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее
|
||||||
|
-- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
|
||||||
|
WHERE scrape_proxy_source_bans.banned_until <= now()
|
||||||
|
OR scrape_proxy_source_bans.reason = CAST(:reason AS text)
|
||||||
|
OR CAST(:reason AS text) = CAST(:live_reason AS text)
|
||||||
RETURNING ban_count, banned_until
|
RETURNING ban_count, banned_until
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"proxy_id": proxy_id,
|
"proxy_id": proxy_id,
|
||||||
"source": source,
|
"source": source,
|
||||||
"reason": f"banned:{source}",
|
"reason": effective_reason,
|
||||||
|
"live_reason": live_reason,
|
||||||
"base_hours": SOURCE_BAN_BASE_HOURS,
|
"base_hours": SOURCE_BAN_BASE_HOURS,
|
||||||
"max_hours": SOURCE_BAN_MAX_HOURS,
|
"max_hours": SOURCE_BAN_MAX_HOURS,
|
||||||
"max_fails": MAX_CONSECUTIVE_FAILS,
|
"max_fails": MAX_CONSECUTIVE_FAILS,
|
||||||
|
|
@ -833,10 +928,40 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||||
row["banned_until"],
|
row["banned_until"],
|
||||||
row["ban_count"],
|
row["ban_count"],
|
||||||
)
|
)
|
||||||
return
|
return "banned"
|
||||||
|
|
||||||
|
# 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита
|
||||||
|
# последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога
|
||||||
|
# (на решение уже не влияет), но диагноз должен называть то, что произошло.
|
||||||
|
holder = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT reason, banned_until
|
||||||
|
FROM scrape_proxy_source_bans
|
||||||
|
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||||
|
AND source = CAST(:source AS text)
|
||||||
|
AND banned_until > now()
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"proxy_id": proxy_id, "source": source},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
if holder is not None and holder["reason"] != effective_reason:
|
||||||
|
logger.info(
|
||||||
|
"proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт "
|
||||||
|
"%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, "
|
||||||
|
"иначе проба присвоила бы чужой бан и потом сняла бы его как свой)",
|
||||||
|
proxy_id,
|
||||||
|
source,
|
||||||
|
holder["reason"],
|
||||||
|
holder["banned_until"],
|
||||||
|
effective_reason,
|
||||||
|
)
|
||||||
|
return "deferred"
|
||||||
|
|
||||||
# 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем
|
|
||||||
# текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет).
|
|
||||||
current = (
|
current = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -849,7 +974,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||||
)
|
)
|
||||||
if current is None:
|
if current is None:
|
||||||
logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id)
|
logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id)
|
||||||
else:
|
return "missing"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
|
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
|
||||||
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
|
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
|
||||||
|
|
@ -857,9 +982,17 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||||
proxy_id,
|
proxy_id,
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
return "protected"
|
||||||
|
|
||||||
|
|
||||||
def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
|
def clear_source_bans(
|
||||||
|
db: Session,
|
||||||
|
proxy_id: int,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
reason: str,
|
||||||
|
only_reason: str | None = None,
|
||||||
|
) -> int:
|
||||||
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
|
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
|
||||||
|
|
||||||
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
|
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
|
||||||
|
|
@ -882,6 +1015,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
||||||
SOURCE_BAN_BASE_HOURS.
|
SOURCE_BAN_BASE_HOURS.
|
||||||
|
|
||||||
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
||||||
|
|
||||||
|
`only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
|
||||||
|
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
|
||||||
|
свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан,
|
||||||
|
распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация
|
||||||
|
IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной
|
||||||
|
целиком. None — снимать всё, как и раньше.
|
||||||
"""
|
"""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -889,10 +1029,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
||||||
DELETE FROM scrape_proxy_source_bans
|
DELETE FROM scrape_proxy_source_bans
|
||||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||||
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
||||||
|
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
|
||||||
RETURNING source
|
RETURNING source
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"proxy_id": proxy_id, "source": source},
|
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
db.commit()
|
db.commit()
|
||||||
if rows:
|
if rows:
|
||||||
|
|
@ -906,6 +1047,79 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_source_probe(
|
||||||
|
db: Session,
|
||||||
|
proxy_id: int,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
ok: bool,
|
||||||
|
fail_kind: str | None = None,
|
||||||
|
detail: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800).
|
||||||
|
|
||||||
|
Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или
|
||||||
|
не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает
|
||||||
|
`acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим.
|
||||||
|
|
||||||
|
КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница
|
||||||
|
другая — здесь судится ПАРА, а не узел):
|
||||||
|
- "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored".
|
||||||
|
Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз);
|
||||||
|
- "proxy" — через этот узел до площадки не доходит транспорт
|
||||||
|
(NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары;
|
||||||
|
- "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку
|
||||||
|
(200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары.
|
||||||
|
Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для
|
||||||
|
ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя.
|
||||||
|
|
||||||
|
Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный
|
||||||
|
боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить
|
||||||
|
дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная
|
||||||
|
половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не
|
||||||
|
присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь
|
||||||
|
присвоенная строка уже «своя».
|
||||||
|
|
||||||
|
Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего
|
||||||
|
своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не
|
||||||
|
пишется (голодание хуже работы через плохой узел).
|
||||||
|
|
||||||
|
Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned`
|
||||||
|
("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном
|
||||||
|
только реально записанный бан.
|
||||||
|
"""
|
||||||
|
if ok:
|
||||||
|
cleared = clear_source_bans(
|
||||||
|
db,
|
||||||
|
proxy_id,
|
||||||
|
source=source,
|
||||||
|
reason=f"browser probe OK for source={source} ({detail})",
|
||||||
|
only_reason=_PROBE_BAN_REASON,
|
||||||
|
)
|
||||||
|
return "cleared" if cleared else "ok"
|
||||||
|
|
||||||
|
if fail_kind not in ("proxy", "page"):
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре "
|
||||||
|
"(fail_kind=%s): %s — вердикт не пишем",
|
||||||
|
proxy_id,
|
||||||
|
source,
|
||||||
|
fail_kind,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
return "ignored"
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан "
|
||||||
|
"пары, узел остаётся первосортным для остальных площадок (#2800)",
|
||||||
|
proxy_id,
|
||||||
|
source,
|
||||||
|
fail_kind,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
|
||||||
|
|
||||||
|
|
||||||
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
|
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
|
||||||
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
|
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
|
||||||
|
|
||||||
|
|
@ -969,8 +1183,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
||||||
return False, None, None, "other"
|
return False, None, None, "other"
|
||||||
|
|
||||||
|
|
||||||
async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str:
|
def _probe_sources_for(affinity: str) -> list[str]:
|
||||||
"""Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health.
|
"""Источники, которым узел с такой affinity МОЖЕТ достаться (#2800).
|
||||||
|
|
||||||
|
Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`.
|
||||||
|
Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику,
|
||||||
|
которой никто не воспользуется.
|
||||||
|
|
||||||
|
ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих
|
||||||
|
свободных нет) — такая пара останется без вердикта и решится как раньше, по факту
|
||||||
|
прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же
|
||||||
|
только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос
|
||||||
|
бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4.
|
||||||
|
"""
|
||||||
|
from scraper_kit.browser_fetcher import PROBE_SOURCES
|
||||||
|
|
||||||
|
return [s for s in PROBE_SOURCES if affinity in (s, "any")]
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_pair_probes(
|
||||||
|
db: Session, proxy_id: int, url: str, kind: str, affinity: str
|
||||||
|
) -> tuple[str, dict[str, int]]:
|
||||||
|
"""Крест «этот узел × каждая его площадка» + запись вердиктов (#2800).
|
||||||
|
|
||||||
|
Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам).
|
||||||
|
|
||||||
|
Два уровня вердикта, и они не пересекаются:
|
||||||
|
- ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке
|
||||||
|
отдельно, это то, что читает `acquire(source)`;
|
||||||
|
- УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по
|
||||||
|
итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел
|
||||||
|
работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки
|
||||||
|
узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов
|
||||||
|
появилось бы новое.
|
||||||
|
|
||||||
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
|
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
|
||||||
healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не
|
healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не
|
||||||
|
|
@ -978,18 +1223,60 @@ async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) ->
|
||||||
"""
|
"""
|
||||||
from scraper_kit.browser_fetcher import probe_proxy_via_browser
|
from scraper_kit.browser_fetcher import probe_proxy_via_browser
|
||||||
|
|
||||||
|
counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
|
||||||
|
fail_kinds: list[str] = []
|
||||||
|
any_ok = False
|
||||||
|
last_detail = ""
|
||||||
|
|
||||||
|
for source in _probe_sources_for(affinity):
|
||||||
try:
|
try:
|
||||||
ok, fail_kind, detail = await probe_proxy_via_browser(
|
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||||
_settings.browser_http_endpoint, url, proxy_kind=kind
|
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
|
||||||
|
)
|
||||||
|
if not ok and fail_kind == "proxy":
|
||||||
|
# Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает
|
||||||
|
# флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор
|
||||||
|
# идёт по уже поднятому браузеру с тем же прокси — секунды, и только
|
||||||
|
# на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью
|
||||||
|
# (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания.
|
||||||
|
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||||
|
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан",
|
"proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан",
|
||||||
proxy_id,
|
proxy_id,
|
||||||
|
source,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return "ignored"
|
continue
|
||||||
return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail)
|
|
||||||
|
counters["pair_checked"] += 1
|
||||||
|
last_detail = detail
|
||||||
|
if ok:
|
||||||
|
any_ok = True
|
||||||
|
else:
|
||||||
|
fail_kinds.append(fail_kind or "other")
|
||||||
|
outcome = mark_source_probe(
|
||||||
|
db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail
|
||||||
|
)
|
||||||
|
if outcome == "banned":
|
||||||
|
counters["pair_banned"] += 1
|
||||||
|
elif outcome == "cleared":
|
||||||
|
counters["pair_cleared"] += 1
|
||||||
|
|
||||||
|
if counters["pair_checked"] == 0:
|
||||||
|
return "ignored", counters # крест не состоялся — узел не судим
|
||||||
|
|
||||||
|
if any_ok:
|
||||||
|
return mark_browser_health(db, proxy_id, True, detail=last_detail), counters
|
||||||
|
# Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ —
|
||||||
|
# транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле».
|
||||||
|
node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0]
|
||||||
|
return (
|
||||||
|
mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail),
|
||||||
|
counters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mask(url: str) -> str:
|
def _mask(url: str) -> str:
|
||||||
|
|
@ -1025,18 +1312,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
|
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
|
||||||
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
|
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
|
||||||
|
|
||||||
БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером
|
БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не
|
||||||
дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же
|
проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ
|
||||||
тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на
|
САЙДКАР — по одной навигации на каждую площадку, которую этот узел может
|
||||||
robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и
|
обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси,
|
||||||
никогда не смешивается с consecutive_fails/enabled. Гейт — settings.
|
потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в
|
||||||
use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба
|
scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные
|
||||||
измеряла бы то, чем никто не пользуется.
|
browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт —
|
||||||
|
settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и
|
||||||
|
проба измеряла бы то, чем никто не пользуется.
|
||||||
|
|
||||||
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
||||||
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
||||||
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
|
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
|
||||||
browser_unfit, browser_refit}.
|
browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}.
|
||||||
"""
|
"""
|
||||||
reaped = reap_stale_leases(db)
|
reaped = reap_stale_leases(db)
|
||||||
|
|
||||||
|
|
@ -1044,7 +1333,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT id, url, kind, enabled, disabled_reason,
|
SELECT id, url, kind, enabled, disabled_reason, provider_affinity,
|
||||||
(browser_check_at IS NULL
|
(browser_check_at IS NULL
|
||||||
OR browser_check_at < now() - make_interval(
|
OR browser_check_at < now() - make_interval(
|
||||||
mins => CAST(:browser_probe_minutes AS integer)
|
mins => CAST(:browser_probe_minutes AS integer)
|
||||||
|
|
@ -1075,6 +1364,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
browser_ok = 0
|
browser_ok = 0
|
||||||
browser_unfit = 0
|
browser_unfit = 0
|
||||||
browser_refit = 0
|
browser_refit = 0
|
||||||
|
pair_checked = 0
|
||||||
|
pair_banned = 0
|
||||||
|
pair_cleared = 0
|
||||||
for row in proxies:
|
for row in proxies:
|
||||||
proxy_id = int(row["id"])
|
proxy_id = int(row["id"])
|
||||||
url = str(row["url"])
|
url = str(row["url"])
|
||||||
|
|
@ -1104,8 +1396,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
|
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
|
||||||
# не тот узел», из-за которого #2723 и появилась.
|
# не тот узел», из-за которого #2723 и появилась.
|
||||||
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
|
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
|
||||||
outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"]))
|
outcome, pair_counters = await _run_pair_probes(
|
||||||
|
db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"])
|
||||||
|
)
|
||||||
browser_checked += 1
|
browser_checked += 1
|
||||||
|
pair_checked += pair_counters["pair_checked"]
|
||||||
|
pair_banned += pair_counters["pair_banned"]
|
||||||
|
pair_cleared += pair_counters["pair_cleared"]
|
||||||
if outcome in ("ok", "refit"):
|
if outcome in ("ok", "refit"):
|
||||||
browser_ok += 1
|
browser_ok += 1
|
||||||
if outcome == "refit":
|
if outcome == "refit":
|
||||||
|
|
@ -1135,7 +1432,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
|
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
|
||||||
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d",
|
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d "
|
||||||
|
"pair_checked=%d pair_banned=%d pair_cleared=%d",
|
||||||
reaped,
|
reaped,
|
||||||
checked,
|
checked,
|
||||||
ok_count,
|
ok_count,
|
||||||
|
|
@ -1146,6 +1444,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
browser_ok,
|
browser_ok,
|
||||||
browser_unfit,
|
browser_unfit,
|
||||||
browser_refit,
|
browser_refit,
|
||||||
|
pair_checked,
|
||||||
|
pair_banned,
|
||||||
|
pair_cleared,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"reaped": reaped,
|
"reaped": reaped,
|
||||||
|
|
@ -1160,4 +1461,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
"browser_ok": browser_ok,
|
"browser_ok": browser_ok,
|
||||||
"browser_unfit": browser_unfit,
|
"browser_unfit": browser_unfit,
|
||||||
"browser_refit": browser_refit,
|
"browser_refit": browser_refit,
|
||||||
|
# Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и
|
||||||
|
# pair_banned=2 одновременно — это не противоречие, а точный диагноз
|
||||||
|
# «браузер через узел работает, но две площадки его не пускают».
|
||||||
|
"pair_checked": pair_checked,
|
||||||
|
"pair_banned": pair_banned,
|
||||||
|
"pair_cleared": pair_cleared,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
from app.services.scraper_adapters import (
|
||||||
|
RealMatcherAdapter,
|
||||||
|
RealProxyProvider,
|
||||||
|
RealScraperConfig,
|
||||||
|
)
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -230,7 +234,13 @@ async def backfill_cian_history(
|
||||||
|
|
||||||
enrichment = None
|
enrichment = None
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
|
# proxy_provider (#2767): тот же сожжённый env-узел бил и сюда —
|
||||||
|
# это второй вызывающий fetch_newbuilding, чинить надо оба.
|
||||||
|
enrichment = await fetch_newbuilding(
|
||||||
|
zhk_url,
|
||||||
|
config=RealScraperConfig(),
|
||||||
|
proxy_provider=RealProxyProvider(),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cian_newbuilding fetch failed for house_id=%s url=%s: %s",
|
"cian_newbuilding fetch failed for house_id=%s url=%s: %s",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ TTL для avito берётся из settings.avito_stale_ttl_days (env AVITO_ST
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -127,6 +128,107 @@ DEFAULT_MIN_CONFIRMATIONS = 500
|
||||||
_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))"
|
_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Пол TTL по измеренному циклу переобхода (#2659) ───────────────────────────
|
||||||
|
# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на
|
||||||
|
# вопрос, из-за которого заведён #2659: «а достаточно ли ttl_days, чтобы молчание
|
||||||
|
# означало снятие?». Пока свип возвращается к строке реже, чем раз в ttl_days,
|
||||||
|
# TTL меряет НАШУ выборку, а не жизнь объявления, — и источник при этом полностью
|
||||||
|
# здоров, так что гейт молчит.
|
||||||
|
#
|
||||||
|
# ЗАМЕР НА ПРОДЕ 2026-08-09, из-за которого этот пол существует.
|
||||||
|
# С момента деплоя гейта (06.08) TTL снял 1 028 строк; 127 из них (12.4%) УЖЕ снова
|
||||||
|
# активны — свип нашёл их живыми через 1-3 суток и вернул сам (upsert в
|
||||||
|
# scraper_kit/base.py ставит is_active = true). В единственном городе с настоящим
|
||||||
|
# покрытием доля ложных снятий 100%:
|
||||||
|
# cian Екатеринбург 103 снято → 103 снова активны
|
||||||
|
# yandex Екатеринбург 24 снято → 24 снова активны
|
||||||
|
# cian/yandex без города 901 снято → 0 вернулись (их свип не обходит вовсе)
|
||||||
|
# Возраст на момент снятия у всех 127: 29.9..30.3 суток при TTL=30 — то есть TTL
|
||||||
|
# срабатывал ровно на границе, а свип возвращался к строке на 31-34-е сутки.
|
||||||
|
#
|
||||||
|
# ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ НОВОЙ КОНСТАНТОЙ. Разрывы переобхода, суток
|
||||||
|
# (listing_source_snapshots, 40 суток, посчитано по срезу TTL-джобы):
|
||||||
|
# источник/сегмент p90 p99 TTL сейчас TTL/p99
|
||||||
|
# domklik vtorichka 1.9 3.1 14 4.5 ← сплошное суточное покрытие
|
||||||
|
# cian vtorichka 10.9 26.6 30 1.1
|
||||||
|
# yandex vtorichka 5.7 43.0 30 0.7
|
||||||
|
# avito vtorichka 29.1 42.1 10 0.24 ← отсюда 9 033 строки
|
||||||
|
# Домклик — контрольная группа: при почти полном суточном обходе TTL=14 лежит в
|
||||||
|
# 4.5 раза выше хвоста, и снятие у него действительно означает снятие. У остальных
|
||||||
|
# трёх порог ниже собственного хвоста обхода — руками подобранное число и есть
|
||||||
|
# корень #2659, поэтому чинить его вторым руками подобранным числом бессмысленно.
|
||||||
|
#
|
||||||
|
# ЧТО МЕРЯЕМ ВМЕСТО КОНСТАНТЫ: факт, а не оценку. «Какой самый большой возраст, при
|
||||||
|
# котором свип за последнее окно ДОКАЗАЛ, что объявление живо» — то есть насколько
|
||||||
|
# старую строку он только что нашёл на площадке. Если свип буквально вчера вернул к
|
||||||
|
# жизни строку, молчавшую 40 суток, то 30 суток молчания не доказывают ничего.
|
||||||
|
# Пол = квантиль этого распределения, эффективный TTL = max(ttl_days, пол).
|
||||||
|
#
|
||||||
|
# Считается по ТОМУ ЖЕ срезу (source + segments) и по ТОЙ ЖЕ колонке свежести, что
|
||||||
|
# и UPDATE. Предыдущее наблюдение берётся из listing_source_snapshots — единственной
|
||||||
|
# истории свежести, что у нас есть; расхождение listings.<col> и
|
||||||
|
# listing_sources.last_seen_at замерено на проде и не превышает 0.5 суток в среднем
|
||||||
|
# (максимум 0), что на шкале 30-70 суток шум.
|
||||||
|
#
|
||||||
|
# КВАНТИЛЬ — калибровочная ручка, не догма. 0.99 подобран по требованию «пол обязан
|
||||||
|
# накрыть 127 доказанных ложных снятий», у которых возраст был 29.9..30.3: замер
|
||||||
|
# того же запроса на проде даёт 34.0 для cian/vtorichka и 74.3 для yandex/vtorichka.
|
||||||
|
# Ниже 0.99 опускать нельзя без нового замера. Ручка живёт в default_params
|
||||||
|
# расписания (revisit_floor_quantile), 0 -> пол выключен.
|
||||||
|
#
|
||||||
|
# ПОБОЧНЫЙ ЭФФЕКТ, КОТОРЫЙ ЗДЕСЬ НАМЕРЕННЫЙ: после провала сбора хвост разрывов
|
||||||
|
# распухает (свип разгребает завал и находит очень старые строки), пол поднимается,
|
||||||
|
# и деактивация замирает сама — без отдельного детектора банов. Когда завал разобран,
|
||||||
|
# хвост схлопывается и пол опускается обратно. Это ровно то поведение, которого
|
||||||
|
# issue просил от «гейта по банам», но выраженное через результат, а не через причину.
|
||||||
|
#
|
||||||
|
# ПОТОЛОК: пол не может превысить глубину истории снимков. Если снимок за нужную
|
||||||
|
# дату не писался (дыры на проде есть — 30.07, 01.08), берётся ближайший более
|
||||||
|
# ранний; при полном отсутствии снимков пол не считается и TTL остаётся как задан.
|
||||||
|
DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99
|
||||||
|
|
||||||
|
_REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any:
|
||||||
|
"""Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива.
|
||||||
|
|
||||||
|
Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт
|
||||||
|
разрыв переобхода в сутках; берём его квантиль по срезу source+segments.
|
||||||
|
Только строки, у которых свежесть реально сдвинулась, — то есть выжившие,
|
||||||
|
а не «мы к ним не приходили».
|
||||||
|
|
||||||
|
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
|
||||||
|
Значения — param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type).
|
||||||
|
"""
|
||||||
|
segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER if with_segments else ""
|
||||||
|
return text(
|
||||||
|
f"""
|
||||||
|
SELECT percentile_disc(CAST(:revisit_quantile AS double precision))
|
||||||
|
WITHIN GROUP (
|
||||||
|
ORDER BY EXTRACT(epoch FROM (l.{staleness_column} - prev.last_seen_at))
|
||||||
|
/ 86400.0
|
||||||
|
)
|
||||||
|
FROM listings l
|
||||||
|
JOIN listing_sources ls
|
||||||
|
ON ls.listing_id = l.id
|
||||||
|
AND ls.ext_source = l.source
|
||||||
|
JOIN listing_source_snapshots prev
|
||||||
|
ON prev.listing_source_id = ls.id
|
||||||
|
AND prev.snapshot_date = (
|
||||||
|
SELECT max(snapshot_date)
|
||||||
|
FROM listing_source_snapshots
|
||||||
|
WHERE snapshot_date
|
||||||
|
<= CURRENT_DATE - CAST(:health_window_days AS integer)
|
||||||
|
)
|
||||||
|
WHERE l.source = :listing_source
|
||||||
|
AND l.{staleness_column}
|
||||||
|
> NOW() - CAST(:health_window_days || ' days' AS interval)
|
||||||
|
AND l.{staleness_column} > prev.last_seen_at{segment_filter}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
|
def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
|
||||||
"""SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
|
"""SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
|
||||||
|
|
||||||
|
|
@ -209,6 +311,7 @@ def deactivate_stale_listings(
|
||||||
staleness_column: str = "last_seen_at",
|
staleness_column: str = "last_seen_at",
|
||||||
min_confirmations: int = 0,
|
min_confirmations: int = 0,
|
||||||
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
||||||
|
revisit_floor_quantile: float = 0.0,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
|
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
|
||||||
|
|
||||||
|
|
@ -229,6 +332,11 @@ def deactivate_stale_listings(
|
||||||
вызывают старые тесты и совместимая обёртка); реальные значения приходят
|
вызывают старые тесты и совместимая обёртка); реальные значения приходят
|
||||||
из default_params расписания, см. миграцию 219 и комментарий выше.
|
из default_params расписания, см. миграцию 219 и комментарий выше.
|
||||||
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
|
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
|
||||||
|
revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659).
|
||||||
|
Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой;
|
||||||
|
эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так
|
||||||
|
вызывают старые тесты и совместимая обёртка), рабочее значение —
|
||||||
|
DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше.
|
||||||
|
|
||||||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||||||
Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
|
Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
|
||||||
|
|
@ -236,7 +344,8 @@ def deactivate_stale_listings(
|
||||||
|
|
||||||
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
|
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
|
||||||
Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
|
Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
|
||||||
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута.
|
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял
|
||||||
|
TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
|
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
|
||||||
|
|
@ -292,6 +401,44 @@ def deactivate_stale_listings(
|
||||||
)
|
)
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
|
# Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же
|
||||||
|
# срезу. Поднимает порог, никогда не опускает: max(), а не замена.
|
||||||
|
effective_ttl_days = ttl_days
|
||||||
|
if revisit_floor_quantile > 0:
|
||||||
|
floor_params: dict[str, Any] = {
|
||||||
|
"listing_source": listing_source,
|
||||||
|
"health_window_days": health_window_days,
|
||||||
|
"revisit_quantile": revisit_floor_quantile,
|
||||||
|
}
|
||||||
|
if segments is not None:
|
||||||
|
floor_params["segments"] = segments
|
||||||
|
floor_days = db.execute(
|
||||||
|
_build_revisit_floor_sql(staleness_column, with_segments=segments is not None),
|
||||||
|
floor_params,
|
||||||
|
).scalar()
|
||||||
|
# NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках).
|
||||||
|
# Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего.
|
||||||
|
if floor_days is not None:
|
||||||
|
counters["revisit_floor_days"] = ceil(float(floor_days))
|
||||||
|
effective_ttl_days = max(ttl_days, counters["revisit_floor_days"])
|
||||||
|
counters["ttl_days_effective"] = effective_ttl_days
|
||||||
|
if effective_ttl_days > ttl_days:
|
||||||
|
logger.warning(
|
||||||
|
"deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: "
|
||||||
|
"свип за %d сут доказал живой строку, молчавшую %d сут "
|
||||||
|
"(квантиль %.3f, segments=%r) — при ttl_days=%d снятие означало бы "
|
||||||
|
"«мы не дошли», а не «объявление снято»",
|
||||||
|
listing_source,
|
||||||
|
run_id,
|
||||||
|
ttl_days,
|
||||||
|
effective_ttl_days,
|
||||||
|
health_window_days,
|
||||||
|
counters["revisit_floor_days"],
|
||||||
|
revisit_floor_quantile,
|
||||||
|
segments,
|
||||||
|
ttl_days,
|
||||||
|
)
|
||||||
|
|
||||||
# segments is None -> все сегменты (поведение avito). segments=[...] -> только
|
# segments is None -> все сегменты (поведение avito). segments=[...] -> только
|
||||||
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
|
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
|
||||||
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
|
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
|
||||||
|
|
@ -299,7 +446,7 @@ def deactivate_stale_listings(
|
||||||
if segments is not None:
|
if segments is not None:
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
"listing_source": listing_source,
|
"listing_source": listing_source,
|
||||||
"ttl_days": ttl_days,
|
"ttl_days": effective_ttl_days,
|
||||||
"segments": segments,
|
"segments": segments,
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +454,7 @@ def deactivate_stale_listings(
|
||||||
else:
|
else:
|
||||||
params = {
|
params = {
|
||||||
"listing_source": listing_source,
|
"listing_source": listing_source,
|
||||||
"ttl_days": ttl_days,
|
"ttl_days": effective_ttl_days,
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
}
|
}
|
||||||
result = db.execute(_build_all_segments_sql(staleness_column), params)
|
result = db.execute(_build_all_segments_sql(staleness_column), params)
|
||||||
|
|
@ -318,10 +465,11 @@ def deactivate_stale_listings(
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"deactivate_stale source=%s run_id=%d done: deactivated=%d "
|
"deactivate_stale source=%s run_id=%d done: deactivated=%d "
|
||||||
"(ttl_days=%d, segments=%r, staleness_column=%s)",
|
"(ttl_days=%d эффективный, задан %d, segments=%r, staleness_column=%s)",
|
||||||
listing_source,
|
listing_source,
|
||||||
run_id,
|
run_id,
|
||||||
counters["deactivated"],
|
counters["deactivated"],
|
||||||
|
effective_ttl_days,
|
||||||
ttl_days,
|
ttl_days,
|
||||||
segments,
|
segments,
|
||||||
staleness_column,
|
staleness_column,
|
||||||
|
|
|
||||||
|
|
@ -399,9 +399,15 @@ async def backfill_newbuilding_enrichment(
|
||||||
save_newbuilding_enrichment,
|
save_newbuilding_enrichment,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.scraper_adapters import RealScraperConfig
|
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||||
|
|
||||||
scraper_config = RealScraperConfig()
|
scraper_config = RealScraperConfig()
|
||||||
|
# #2767: обогащение было ЕДИНСТВЕННЫМ cian-путём мимо пула прокси — весь сбор шёл
|
||||||
|
# через env-узел сайдкара, и когда Циан забанил его exit-IP, 8 суток по 25 попыток
|
||||||
|
# уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠
|
||||||
|
# «включить пул»: реально пул задействуется, только если включён
|
||||||
|
# config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding).
|
||||||
|
proxy_provider = RealProxyProvider()
|
||||||
|
|
||||||
result = NewbuildingEnrichBackfillResult()
|
result = NewbuildingEnrichBackfillResult()
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
|
@ -525,7 +531,9 @@ async def backfill_newbuilding_enrichment(
|
||||||
# ── Fetch (network; anti-bot surface) ──────────────────────────────
|
# ── Fetch (network; anti-bot surface) ──────────────────────────────
|
||||||
enrichment = None
|
enrichment = None
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_newbuilding(zhk_url, config=scraper_config)
|
enrichment = await fetch_newbuilding(
|
||||||
|
zhk_url, config=scraper_config, proxy_provider=proxy_provider
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc
|
"newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc
|
||||||
|
|
@ -535,8 +543,12 @@ async def backfill_newbuilding_enrichment(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if enrichment is None:
|
if enrichment is None:
|
||||||
|
# Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога
|
||||||
|
# читается дальше как факт и один раз уже увела диагноз не туда. Причина
|
||||||
|
# печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers).
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"newbuilding fetch returned None house_id=%s url=%s (captcha / parse miss?)",
|
"newbuilding fetch returned None house_id=%s url=%s — причина в строке "
|
||||||
|
"'initialState extraction failed' выше",
|
||||||
house_id,
|
house_id,
|
||||||
zhk_url,
|
zhk_url,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,21 @@ BATCHING (не единый DELETE по всей таблице):
|
||||||
don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier
|
don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier
|
||||||
committed batches deleted (correct, not rolled back) and mark_failed records the
|
committed batches deleted (correct, not rolled back) and mark_failed records the
|
||||||
partial counters reached so far.
|
partial counters reached so far.
|
||||||
|
|
||||||
|
Payments retention (PR #2754): the `created_by IS NULL` population above is EXACTLY
|
||||||
|
the future paying-customer population -- the owner sells this report to
|
||||||
|
individuals for money, and a paid row must outlive the 24h `expires_at` link TTL
|
||||||
|
(a separate column, `retain_until`, set by the -- separate, not-yet-existing --
|
||||||
|
payment fulfillment code to now() + settings.trade_in_paid_retention_days, NOT
|
||||||
|
a change to `expires_at` itself). Two independent safeguards were added to
|
||||||
|
`_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS payments)
|
||||||
|
plus a pre-flight count in `purge_expired_trade_in_data` that refuses to run at
|
||||||
|
all if it finds an ANOMALOUS paid candidate -- see the SQL constants and
|
||||||
|
`_preflight_paid_candidates` below for the mechanics (deep-review finding
|
||||||
|
2026-08-06 MEDIUM on PR #2754: the pre-flight predicate itself must ALSO carry
|
||||||
|
`retain_until IS NULL`, otherwise a perfectly healthy paid row trips it and
|
||||||
|
wedges the job permanently -- see that function's docstring). No payment code
|
||||||
|
lives in this file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -74,6 +89,26 @@ logger = logging.getLogger(__name__)
|
||||||
# remainder simply drains on the next nightly run (idempotent, no data loss risk).
|
# remainder simply drains on the next nightly run (idempotent, no data loss risk).
|
||||||
_DEFAULT_MAX_BATCHES = 20
|
_DEFAULT_MAX_BATCHES = 20
|
||||||
|
|
||||||
|
#
|
||||||
|
# Payments retention (2026-08-06, PR #2754): два независимых предохранителя
|
||||||
|
# добавлены к тому же предикату ПЕРЕД тем, как платёжный код появился в
|
||||||
|
# проекте (мина уже была заряжена: без них джоба удаляла бы будущих платящих
|
||||||
|
# клиентов). Отдельная колонка retain_until (не подъём expires_at) — потому
|
||||||
|
# что expires_at глобальный TTL расчёта на ВСЕ строки (включая неоплаченные)
|
||||||
|
# и печатается в PDF/UI как «актуальность расчёта»; поднять его до года
|
||||||
|
# означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в
|
||||||
|
# документе клиента про срок актуальности цифры:
|
||||||
|
# 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная
|
||||||
|
# строка (retain_until IS NOT NULL, migration 240) не удаляется джобой
|
||||||
|
# В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем
|
||||||
|
# через год после первой продажи. `retain_until` ставится сервисным
|
||||||
|
# кодом платёжного контура (ещё не существует в этом PR) на now() +
|
||||||
|
# settings.trade_in_paid_retention_days.
|
||||||
|
# 2. `NOT EXISTS (payments)` — независимая страховка на случай, если выдача
|
||||||
|
# забыла проставить retain_until (баг/гонка/ручной INSERT): строка,
|
||||||
|
# которой коснулись деньги, переживёт джобу даже без корректного (1).
|
||||||
|
# `payments` создана migration 233 (payments_estimate_idx — дешёвый терм).
|
||||||
|
# См. также _preflight_paid_candidates ниже — та же логика ДО первого батча.
|
||||||
_DELETE_EXPIRED_ESTIMATES_SQL = text(
|
_DELETE_EXPIRED_ESTIMATES_SQL = text(
|
||||||
"""
|
"""
|
||||||
DELETE FROM trade_in_estimates
|
DELETE FROM trade_in_estimates
|
||||||
|
|
@ -81,12 +116,39 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text(
|
||||||
SELECT id FROM trade_in_estimates
|
SELECT id FROM trade_in_estimates
|
||||||
WHERE expires_at < NOW()
|
WHERE expires_at < NOW()
|
||||||
AND created_by IS NULL
|
AND created_by IS NULL
|
||||||
|
AND retain_until IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM payments p WHERE p.estimate_id = trade_in_estimates.id
|
||||||
|
)
|
||||||
ORDER BY expires_at
|
ORDER BY expires_at
|
||||||
LIMIT CAST(:batch_size AS int)
|
LIMIT CAST(:batch_size AS int)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Pre-flight (см. _preflight_paid_candidates). deep-review finding 2026-08-06
|
||||||
|
# MEDIUM (PR #2754): первая редакция считала по БАЗОВОМУ предикату БЕЗ
|
||||||
|
# retain_until вообще -- а это ловит и штатно-здоровые оплаченные строки
|
||||||
|
# (retain_until проставлен, есть payments) точно так же, как настоящую
|
||||||
|
# аномалию (retain_until НЕ проставлен, но payments есть) -- джоба вставала
|
||||||
|
# на первой же честной продаже и больше никогда не запускалась (вместе с ней
|
||||||
|
# вставало и удаление лидов, вызываемое из той же функции ПОСЛЕ этой
|
||||||
|
# проверки -- 180-дневный purge по 152-ФЗ тоже переставал бы работать).
|
||||||
|
# Правильная форма: базовый предикат AND "новый предохранитель НЕ сработал
|
||||||
|
# бы" (retain_until IS NULL) AND "признак аномалии" (payments всё же есть).
|
||||||
|
# Здоровая оплаченная строка (retain_until IS NOT NULL) исключается ЭТИМ
|
||||||
|
# термом -- она и так под DELETE не попадает (см. safeguard 1 выше), тревогу
|
||||||
|
# поднимать не должна.
|
||||||
|
_PREFLIGHT_PAID_CANDIDATES_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT count(*) FROM trade_in_estimates e
|
||||||
|
WHERE e.expires_at < NOW()
|
||||||
|
AND e.created_by IS NULL
|
||||||
|
AND e.retain_until IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
_DELETE_EXPIRED_LEADS_SQL = text(
|
_DELETE_EXPIRED_LEADS_SQL = text(
|
||||||
"""
|
"""
|
||||||
DELETE FROM trade_in_leads
|
DELETE FROM trade_in_leads
|
||||||
|
|
@ -135,6 +197,25 @@ def _drain_expired(
|
||||||
break # caught up -- fewer expired rows left than one batch
|
break # caught up -- fewer expired rows left than one batch
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_paid_candidates(db: Session) -> int:
|
||||||
|
"""Safety gate: count ANOMALOUS purge-candidates -- base predicate, retain_until
|
||||||
|
IS NULL (safeguard 1 did NOT protect the row), AND a payments row exists anyway.
|
||||||
|
|
||||||
|
Runs BEFORE any DELETE batch. A non-zero result means fulfillment failed to set
|
||||||
|
`retain_until` on a row money actually touched (bug/race/manual INSERT) -- this
|
||||||
|
run must not delete anything; see `purge_expired_trade_in_data` below, which
|
||||||
|
aborts before the first batch when this returns non-zero.
|
||||||
|
|
||||||
|
MUST include `retain_until IS NULL` (deep-review finding 2026-08-06 MEDIUM, PR
|
||||||
|
#2754): a healthy paid row (retain_until set, has a payments row) is the EXPECTED
|
||||||
|
steady state one day after every sale -- without this term it counts as a "paid
|
||||||
|
candidate" too, so the very first successful sale permanently wedges this job
|
||||||
|
(mark_failed, zero deletions, forever -- and since leads purge runs from the same
|
||||||
|
function AFTER this check, the unrelated 180-day lead retention would also stop).
|
||||||
|
"""
|
||||||
|
return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
def purge_expired_trade_in_data(
|
def purge_expired_trade_in_data(
|
||||||
db: Session,
|
db: Session,
|
||||||
run_id: int,
|
run_id: int,
|
||||||
|
|
@ -148,10 +229,32 @@ def purge_expired_trade_in_data(
|
||||||
deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed).
|
deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed).
|
||||||
|
|
||||||
Returns {"estimates_deleted": N, "leads_deleted": M}.
|
Returns {"estimates_deleted": N, "leads_deleted": M}.
|
||||||
|
|
||||||
|
Payments retention pre-flight (see `_preflight_paid_candidates`): if any
|
||||||
|
purge-candidate estimate has `retain_until IS NULL` AND a `payments` row (the
|
||||||
|
ANOMALY -- fulfillment failed to set retain_until on a row money touched), the
|
||||||
|
run aborts BEFORE the first DELETE batch (estimates OR leads) -- zero rows
|
||||||
|
deleted, `mark_failed` records why. Healthy paid rows (retain_until set) do NOT
|
||||||
|
trip this -- they never matched the check to begin with. This is deliberately
|
||||||
|
checked outside the `try` below so it can never be caught and silently
|
||||||
|
re-reported as a generic mid-run failure -- it is a distinct, actionable
|
||||||
|
pre-condition failure.
|
||||||
"""
|
"""
|
||||||
batch_size = batch_size or settings.trade_in_purge_batch_size
|
batch_size = batch_size or settings.trade_in_purge_batch_size
|
||||||
max_batches = max_batches or _DEFAULT_MAX_BATCHES
|
max_batches = max_batches or _DEFAULT_MAX_BATCHES
|
||||||
counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0}
|
counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0}
|
||||||
|
|
||||||
|
paid_candidates = _preflight_paid_candidates(db)
|
||||||
|
if paid_candidates:
|
||||||
|
error = (
|
||||||
|
f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates "
|
||||||
|
"row(s) have retain_until IS NULL but a matching payments row -- "
|
||||||
|
"refusing to run, zero rows deleted"
|
||||||
|
)
|
||||||
|
logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error)
|
||||||
|
runs_mod.mark_failed(db, run_id, error, counters)
|
||||||
|
raise RuntimeError(error)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_drain_expired(
|
_drain_expired(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
-- 240_trade_in_estimates_retain_until.sql
|
||||||
|
-- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge
|
||||||
|
-- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен
|
||||||
|
-- жить год на нашей стороне, а не 24ч (см. WHY ниже).
|
||||||
|
-- Номер сверен по `forgejo/main` и всем открытым PR-веткам ДВАЖДЫ: сначала
|
||||||
|
-- как 234 (последняя занятая на момент ветвления была 233_payments.sql), но
|
||||||
|
-- main уехал вперёд и 234 занял `234_scrape_runs_ban_kind_unknown.sql`
|
||||||
|
-- (коммит 0de22f4b) — переименовано в 240 (main max на момент повторной
|
||||||
|
-- сверки — 239, с дырами 235-237; max+1 безопаснее дыр). Урок пятый за
|
||||||
|
-- сутки: сверять номер нужно не только перед первым коммитом, а прямо перед
|
||||||
|
-- пушем/мержем — main не стоит на месте.
|
||||||
|
--
|
||||||
|
-- ── WHY ──────────────────────────────────────────────────────────────────────
|
||||||
|
-- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет
|
||||||
|
-- строки `WHERE expires_at < NOW() AND created_by IS NULL` — это ровно
|
||||||
|
-- популяция будущих платящих физлиц (анонимные B2C-оценки). Владелец продаёт
|
||||||
|
-- отчёт физлицу за 150 ₽: скачанный файл у клиента бессрочно, но ссылка/строка
|
||||||
|
-- на нашей стороне обязана жить дольше 24-часового TTL расчёта — иначе первый
|
||||||
|
-- же прогон purge-джобы после запуска продаж физически и безвозвратно удалит
|
||||||
|
-- уже оплаченное (PDF нигде не хранится, рендерится на лету).
|
||||||
|
--
|
||||||
|
-- `expires_at` НЕ трогаем ни на йоту: это единая глобальная настройка
|
||||||
|
-- (`trade_in_estimate_retention_hours`), она же — печатаемая в PDF/UI дата
|
||||||
|
-- «ДЕЙСТВИТЕЛЕН ДО» (актуальность РАСЧЁТА, а не срок жизни строки), и от неё
|
||||||
|
-- зависит вычисление даты расчёта во фронте (`mappers.ts` fmtDateShift(-24)).
|
||||||
|
-- Поднять её до года означало бы: (а) дать год хранения ВСЕМ строкам, включая
|
||||||
|
-- неоплаченные адреса физлиц — прямое нарушение минимизации по 152-ФЗ;
|
||||||
|
-- (б) напечатать в PDF клиента, что расчёт актуален год.
|
||||||
|
--
|
||||||
|
-- ── WHAT ─────────────────────────────────────────────────────────────────────
|
||||||
|
-- Новая, независимая колонка retain_until — срок жизни ДОСТУПА/СТРОКИ:
|
||||||
|
-- NULL = неоплаченная строка, поведение (чтение/PDF/purge) бит-в-бит текущее.
|
||||||
|
-- Бэкфилла нет — все 1058 существующих строк остаются NULL, ничего не меняется
|
||||||
|
-- для уже созданных оценок (весь B2B pilot-трафик в их числе).
|
||||||
|
-- При оплате (платёжный код — отдельный PR, здесь его нет) сервисный слой
|
||||||
|
-- проставит retain_until = now() + trade_in_paid_retention_days (config.py).
|
||||||
|
--
|
||||||
|
-- Частичный индекс покрывает predicate purge-джобы (migration 231,
|
||||||
|
-- `_DELETE_EXPIRED_ESTIMATES_SQL`) уже С УЧЁТОМ нового терма retain_until —
|
||||||
|
-- заведён вместе с колонкой, а не отдельной миграцией, чтобы purge не начал
|
||||||
|
-- жить без него хотя бы один деплой.
|
||||||
|
--
|
||||||
|
-- ── IDEMPOTENCY ──────────────────────────────────────────────────────────────
|
||||||
|
-- ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS — безопасный re-run.
|
||||||
|
-- Ничего не удаляет, не бэкфиллит, DDL-only (доли секунды на 1058 строках).
|
||||||
|
--
|
||||||
|
-- Dependencies: 001_trade_in_estimates.sql, 233_payments.sql (индекс исключает
|
||||||
|
-- строки со строкой в payments опосредованно через predicate purge-джобы,
|
||||||
|
-- сама таблица payments здесь не читается).
|
||||||
|
-- Apply after: 233_payments.sql.
|
||||||
|
--
|
||||||
|
-- ── lock_timeout — выставлен, хотя гейт (scripts/check-migration-lock-timeout.py)
|
||||||
|
-- этот файл не проверяет ────────────────────────────────────────────────────
|
||||||
|
-- Порог гейта для tradein (NN >= 250) — артефакт: назначен по номеру аварийной
|
||||||
|
-- миграции 250, которую затем сняли с деплоя (#2792, 29f10002). Фактический
|
||||||
|
-- максимум применённого на main — 239, то есть НИ ОДНА миграция в диапазоне
|
||||||
|
-- 240-249 (этот файл включительно) гейтом не проверяется вообще — "проверено
|
||||||
|
-- новых миграций: 0" в логе означает "не проверено ни одного файла", а не
|
||||||
|
-- "все чисты". Сама функция scan() внутри гейта, если прогнать её без
|
||||||
|
-- порогового отсечения, помечает ALTER TABLE ниже как блокирующий DDL без
|
||||||
|
-- lock_timeout. `trade_in_estimates` — самая горячая таблица стека (история,
|
||||||
|
-- история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались
|
||||||
|
-- открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка
|
||||||
|
-- встаёт в очередь ПЕРЕД новыми запросами — за ней начинают ждать обычные
|
||||||
|
-- SELECT приложения (см. `sql.md` § lock_timeout). На 1058 строках сам DDL
|
||||||
|
-- мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Красный
|
||||||
|
-- деплой по таймауту — осознанно принятый в проекте размен (честный отказ
|
||||||
|
-- лучше тихой очереди перед приложением). НЕ убирать как "гейт же не просит".
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
ALTER TABLE trade_in_estimates
|
||||||
|
ADD COLUMN IF NOT EXISTS retain_until timestamptz;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN trade_in_estimates.retain_until IS
|
||||||
|
'До какого момента строку НЕЛЬЗЯ удалять и ссылка обязана открываться '
|
||||||
|
'(оплаченный доступ). Семантика expires_at не меняется: это дата '
|
||||||
|
'актуальности РАСЧЁТА (24ч), она печатается в PDF. NULL = неоплачено, '
|
||||||
|
'поведение бит-в-бит текущее. Задаётся сервисным кодом платёжного контура '
|
||||||
|
'(отдельный PR) на now() + trade_in_paid_retention_days (config.py).';
|
||||||
|
|
||||||
|
-- Частичный индекс под predicate purge-джобы (app/tasks/purge_expired_trade_in_data.py):
|
||||||
|
-- WHERE created_by IS NULL AND retain_until IS NULL AND expires_at < NOW().
|
||||||
|
CREATE INDEX IF NOT EXISTS trade_in_estimates_purge_idx
|
||||||
|
ON trade_in_estimates (expires_at)
|
||||||
|
WHERE created_by IS NULL AND retain_until IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
-- 250_drop_duplicate_expires_at_index.sql
|
||||||
|
-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at).
|
||||||
|
-- Возврат после #2792 (снятие с деплоя) — теперь с lock_timeout, см. #2793/#2791.
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09)
|
||||||
|
-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль
|
||||||
|
-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql.
|
||||||
|
--
|
||||||
|
-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени):
|
||||||
|
-- name indkey indclass indoption indcollation pred am
|
||||||
|
-- trade_in_estimates_expires_idx 22 3127 0 0 — btree
|
||||||
|
-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree
|
||||||
|
-- Совпадает всё: колонка, класс операторов, направление сортировки,
|
||||||
|
-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация,
|
||||||
|
-- отсутствие частичного предиката, метод доступа. Ни один не привязан к
|
||||||
|
-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них
|
||||||
|
-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует
|
||||||
|
-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты
|
||||||
|
-- FDW-пользователю). Гранты живут на таблице, не на индексе.
|
||||||
|
--
|
||||||
|
-- ── Почему у «нулевого» дубля появились сканы ────────────────────────────────
|
||||||
|
-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у
|
||||||
|
-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый
|
||||||
|
-- +0, новый +4). Замер 2026-08-09 16:54 UTC подтверждает картину ещё через
|
||||||
|
-- двое суток: новый 21, старый ВСЁ ЕЩЁ 234. То есть планировщик перевёл на
|
||||||
|
-- новый весь живой трафик, и это устойчивое состояние, а не переходное.
|
||||||
|
--
|
||||||
|
-- Причина не семантическая, а физическая: индексы идентичны, но новый
|
||||||
|
-- собран позже с нуля и плотнее упакован — relpages 5 против 6 у старого,
|
||||||
|
-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск
|
||||||
|
-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost,
|
||||||
|
-- и при прочих равных выигрывает. Никакого нового запроса не появилось:
|
||||||
|
-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого,
|
||||||
|
-- 0.95 у нового) — это один и тот же класс точечных lookup'ов, просто
|
||||||
|
-- переехавший на более свежий индекс. Со временем новый забронзовеет так же
|
||||||
|
-- и они поменялись бы местами обратно.
|
||||||
|
--
|
||||||
|
-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ────────────────────────────
|
||||||
|
-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу
|
||||||
|
-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE
|
||||||
|
-- делал бы full scan». На проде это НЕ так. Фактический план боевого
|
||||||
|
-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод
|
||||||
|
-- 2026-08-07, перепроверено 2026-08-09 — план тот же):
|
||||||
|
-- Limit → Sort (Sort Key: expires_at)
|
||||||
|
-- → Bitmap Heap Scan Filter: (expires_at < now())
|
||||||
|
-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at
|
||||||
|
-- Index Cond: (created_by IS NULL)
|
||||||
|
-- Задача purge ограничена `AND created_by IS NULL` (134 строки из 1061), и
|
||||||
|
-- планировщик берёт именно этот, более селективный индекс, а expires_at
|
||||||
|
-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не
|
||||||
|
-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён
|
||||||
|
-- под конкретный запрос» не существует — запрос его не использует.
|
||||||
|
-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму
|
||||||
|
-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с
|
||||||
|
-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД.
|
||||||
|
--
|
||||||
|
-- ── Планы ДО и ПОСЛЕ ─────────────────────────────────────────────────────────
|
||||||
|
-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе:
|
||||||
|
-- меняется только имя индекса в строке плана и cost на одну страницу спуска.
|
||||||
|
-- ДО (прод, 2026-08-09 16:54 UTC):
|
||||||
|
-- Limit (cost=0.28..58.98 rows=100 width=24)
|
||||||
|
-- → Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..623.07)
|
||||||
|
-- Index Cond: (expires_at < now())
|
||||||
|
-- ПОСЛЕ ожидается тот же узел с именем trade_in_estimates_expires_idx и
|
||||||
|
-- cost, отличающимся на спуск по одной лишней странице. Проверено на чистом
|
||||||
|
-- PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым
|
||||||
|
-- перекосом плотности:
|
||||||
|
-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84)
|
||||||
|
-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30)
|
||||||
|
-- Форма плана, Index Cond и Filter идентичны; отличается только имя.
|
||||||
|
--
|
||||||
|
-- ── Стоимость блокировки и почему здесь SET LOCAL lock_timeout ──────────────
|
||||||
|
-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. УДЕРЖАНИЕ здесь
|
||||||
|
-- дёшево: trade_in_estimates — 1061 строка, heap 1856 kB, сносимый индекс
|
||||||
|
-- 40 kB; DROP INDEX ничего не переписывает (удаление строк каталога плюс
|
||||||
|
-- unlink файла, единицы миллисекунд).
|
||||||
|
--
|
||||||
|
-- Дорого — ОЖИДАНИЕ выдачи лока, и это уже случилось. 2026-08-07 первая
|
||||||
|
-- редакция этого файла (без строки ниже) ждала ACCESS EXCLUSIVE 29 минут за
|
||||||
|
-- чужой аналитической psql-сессией (`CREATE TEMP TABLE tmp_res AS ...`,
|
||||||
|
-- pid 83256), вторая попытка — ещё 16. Четыре прогона деплоя красные,
|
||||||
|
-- четыре смерженных PR не доехали до прода; ждущий ACCESS EXCLUSIVE встаёт
|
||||||
|
-- в очередь ПЕРЕД новыми запросами, поэтому за ним начали ждать и обычные
|
||||||
|
-- SELECT приложения. Файл сняли с деплоя (#2792), конвенцию закрепили
|
||||||
|
-- (#2791: гейт scripts/check-migration-lock-timeout.py + .claude/rules/sql.md).
|
||||||
|
--
|
||||||
|
-- Значение 5 s: снизу ограничено deadlock_timeout (на проде 1 s — сверено
|
||||||
|
-- 2026-08-09) — автоотмена мешающего autovacuum срабатывает только после
|
||||||
|
-- того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным
|
||||||
|
-- autovacuum. Сверху — потолок простоя очереди приложения; против
|
||||||
|
-- наблюдённых 1740 s это в 348 раз меньше. На работу ПОД локом значение не
|
||||||
|
-- влияет вообще.
|
||||||
|
--
|
||||||
|
-- Срабатывание таймаута = красный деплой через 5 секунд с `canceling
|
||||||
|
-- statement due to lock timeout` вместо получасовой очереди. Это ожидаемое
|
||||||
|
-- поведение, а не авария: миграция не помечается применённой, повторить
|
||||||
|
-- позже. CONCURRENTLY здесь не нужен и был бы хуже: он не может выполняться
|
||||||
|
-- внутри блока транзакции, а значит файл пришлось бы оставить без
|
||||||
|
-- BEGIN/COMMIT (см. разбор механики раннера в
|
||||||
|
-- 225_listing_source_snapshots_run_id_idx.sql).
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE.
|
||||||
|
-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет.
|
||||||
|
-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы
|
||||||
|
-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует).
|
||||||
|
--
|
||||||
|
-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс),
|
||||||
|
-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль).
|
||||||
|
-- Deploy order: standalone. Ничего не ждёт и никого не блокирует.
|
||||||
|
--
|
||||||
|
-- Критерий «таблица тиха» (записан ДО, выполнен 2026-08-09 16:54 UTC):
|
||||||
|
-- SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation
|
||||||
|
-- WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid(); → 0
|
||||||
|
--
|
||||||
|
-- Критерий приёмки (записан ДО применения):
|
||||||
|
-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
|
||||||
|
-- 2. EXPLAIN того же запроса показывает Index Scan using
|
||||||
|
-- trade_in_estimates_expires_idx — детерминированная проверка, доступна
|
||||||
|
-- сразу.
|
||||||
|
-- 3. pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx уходит с
|
||||||
|
-- 234. NB: наблюдаемый темп ~7 сканов/сутки (21 скан за трое суток у
|
||||||
|
-- дубля), поэтому «в течение часа» — недостаточное окно; честный срок
|
||||||
|
-- подтверждения ~сутки. Если через сутки счётчик всё ещё 234, значит
|
||||||
|
-- трафик ушёл в Seq Scan — это опровергло бы разбор выше и требовало бы
|
||||||
|
-- отката (вернуть индекс: CREATE INDEX CONCURRENTLY).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
|
||||||
|
-- и в .claude/rules/sql.md § lock_timeout.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx;
|
||||||
|
|
||||||
|
COMMENT ON INDEX trade_in_estimates_expires_idx IS
|
||||||
|
'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752/#2793). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
-- 251_listings_drop_ceiling_height.sql
|
||||||
|
-- Issue #2699 (хвост) — снос DEPRECATED-колонки listings.ceiling_height.
|
||||||
|
--
|
||||||
|
-- Dependencies: 019_listings_alter_cian.sql (завела колонку, numeric(3,2)),
|
||||||
|
-- 238_listings_ceiling_height_unify.sql (перенесла значения в
|
||||||
|
-- канон ceiling_height_m и пометила эту колонку DEPRECATED).
|
||||||
|
-- Apply after: 240_trade_in_estimates_retain_until.sql
|
||||||
|
-- Deploy order: код УЖЕ впереди схемы — писатели сняты PR #2779 (07.08) и с тех
|
||||||
|
-- пор на проде. Это тот случай, когда «код первый» правилен: DROP COLUMN
|
||||||
|
-- безопасен только после того, как ни один живой writer/reader колонки не
|
||||||
|
-- остался. Обратный порядок (снести колонку, потом деплоить код) уронил бы
|
||||||
|
-- скрейпинг.
|
||||||
|
--
|
||||||
|
-- ── ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ ФАЙЛ, А НЕ ЧАСТЬ 238 ───────────────────────────────
|
||||||
|
-- 238 намеренно оставила колонку: «сначала прод должен подтвердить, что колонку
|
||||||
|
-- никто не пишет и не читает. Снос — отдельным шагом». Подтверждение получено,
|
||||||
|
-- ниже — числа.
|
||||||
|
--
|
||||||
|
-- ── КРИТЕРИЙ «ПИСАТЕЛЕЙ НЕТ» (записан ДО, а не после) ───────────────────────
|
||||||
|
-- count(ceiling_height) обязан остаться 8552 (8554 из #2699 минус 2 мусорных
|
||||||
|
-- значения cian, обнулённых шагом 1 миграции 238).
|
||||||
|
-- 2026-08-07 (сразу после 238): 8552
|
||||||
|
-- 2026-08-09 16:54 UTC: 8552
|
||||||
|
-- 2026-08-09 16:57 UTC: 8552
|
||||||
|
-- Двое суток без единой записи. Контроль того, что замер не «мёртвый» (БД жива,
|
||||||
|
-- скрейпинг идёт, просто пишет в канон): за те же 2.5 минуты между двумя
|
||||||
|
-- замерами count(ceiling_height_m) вырос 16133 → 16147, а last_seen_at строк с
|
||||||
|
-- непустым ceiling_height обновлялся в ту же минуту, что и замер. То есть UPDATE
|
||||||
|
-- по этим строкам идут прямо сейчас и НЕ трогают сносимую колонку — это сильнее,
|
||||||
|
-- чем «два дня тишины».
|
||||||
|
--
|
||||||
|
-- ── ПОТРЕБИТЕЛИ: сверка на origin/main перед сносом ─────────────────────────
|
||||||
|
-- `git grep -n 'ceiling_height\b' origin/main -- '*.py' '*.ts' '*.tsx' '*.sql'`
|
||||||
|
-- минус вхождения ceiling_height_m: ни одного обращения к КОЛОНКЕ не осталось.
|
||||||
|
-- Что попало в выдачу и почему это не потребители:
|
||||||
|
-- - имена полей Python-датаклассов enrichment'ов (CianEnrichment.ceiling_height,
|
||||||
|
-- YandexEnrichment.ceiling_height, YandexValuation...) — атрибуты объектов,
|
||||||
|
-- не колонки;
|
||||||
|
-- - `CAST(:ceiling_height AS numeric)` в yandex/detail.py:600 — ИМЯ БИНД-
|
||||||
|
-- ПАРАМЕТРА, а присваивается он колонке ceiling_height_m (соседняя строка);
|
||||||
|
-- то же в cian/detail.py (`:ch`) и base.py (`:ceiling_height_m`);
|
||||||
|
-- - комментарии/докстринги с историей #2699 и тесты, которые как раз
|
||||||
|
-- УТВЕРЖДАЮТ отсутствие колонки в SQL писателей
|
||||||
|
-- (tests/test_ceiling_height_unify_2699.py, test_scraper_admin_apis.py);
|
||||||
|
-- - 019/238 — сами миграции, их переписывать нельзя и не нужно.
|
||||||
|
-- Фронтовых (.ts/.tsx) вхождений нет вообще.
|
||||||
|
--
|
||||||
|
-- ── ЗАВИСИМОСТИ В СХЕМЕ: проверено на проде 2026-08-09, все нули ────────────
|
||||||
|
-- pg_depend по атрибуту listings.ceiling_height ................ 0 объектов
|
||||||
|
-- вьюхи/матвьюхи с 'ceiling' в определении ..................... 0
|
||||||
|
-- индексы listings с 'ceiling' в indexdef ...................... 0
|
||||||
|
-- CHECK/constraint с 'ceiling' ................................. 0
|
||||||
|
-- функции и процедуры с 'ceiling_height' в теле ................ 0
|
||||||
|
-- тела обоих триггеров listings (price_change, set_geom) ....... не упоминают
|
||||||
|
-- pg_publication_rel по listings (column list ломает DROP) ..... 0 (публикаций в БД нет)
|
||||||
|
-- foreign tables НА listings в gendesign-БД (FDW-читатель) ..... 0
|
||||||
|
-- То есть CASCADE не нужен — и не должен появиться: в этом продукте
|
||||||
|
-- `DROP ... CASCADE` уже терял гранты FDW-пользователю (инцидент C3).
|
||||||
|
--
|
||||||
|
-- ── ПОТЕРИ ДАННЫХ НЕТ (проверено, а не предположено) ────────────────────────
|
||||||
|
-- строк, где ceiling_height IS NOT NULL AND ceiling_height_m IS NULL ..... 0
|
||||||
|
-- строк, где заполнены обе ............................................ 8552
|
||||||
|
-- из них расходятся значения .............................................. 0
|
||||||
|
-- Всё содержимое сносимой колонки присутствует в каноне до последнего знака.
|
||||||
|
-- ceiling_height_m на момент написания: 16 147 непустых (после 238 было 15 591 —
|
||||||
|
-- канон растёт, то есть живой).
|
||||||
|
--
|
||||||
|
-- ── СТОИМОСТЬ БЛОКИРОВКИ И ПОЧЕМУ SET LOCAL lock_timeout ───────────────────
|
||||||
|
-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на listings. УДЕРЖАНИЕ
|
||||||
|
-- дёшево и не зависит от размера таблицы: PostgreSQL не переписывает heap, а
|
||||||
|
-- помечает атрибут attisdropped в каталоге (в listings уже 4 таких «пенька» от
|
||||||
|
-- прошлых сносов при 92 живых колонках) — единицы миллисекунд.
|
||||||
|
--
|
||||||
|
-- Дорого ОЖИДАНИЕ выдачи лока, и цена здесь выше, чем у 250: listings — 19 GB,
|
||||||
|
-- 97 540 строк, по ней постоянно идёт скрейпинг (в т.ч. длинные проходы вроде
|
||||||
|
-- avito_full_load). Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
|
||||||
|
-- запросами, поэтому за ним начнут ждать обычные SELECT/UPDATE приложения —
|
||||||
|
-- ровно то, что 2026-08-07 положило деплой на 29 минут (#2791, #2792).
|
||||||
|
-- Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено deadlock_timeout =
|
||||||
|
-- 1 s на проде, сверху — потолок простоя очереди приложения; на работу ПОД
|
||||||
|
-- локом не влияет). Срабатывание = честный красный деплой через 5 секунд,
|
||||||
|
-- миграция не помечается применённой, повторить в окно потише.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- - DROP COLUMN IF EXISTS — безопасный re-run.
|
||||||
|
-- - Без CASCADE: зависимых объектов нет (см. выше), а CASCADE молча снёс бы
|
||||||
|
-- то, что появится позже.
|
||||||
|
-- - Одна DDL-операция внутри BEGIN/COMMIT.
|
||||||
|
-- - Откат: колонку вернуть можно (ALTER TABLE ... ADD COLUMN), но данные в неё
|
||||||
|
-- не восстановятся — они и не нужны, дубликат канона (0 расхождений).
|
||||||
|
--
|
||||||
|
-- Критерий приёмки (записан ДО применения):
|
||||||
|
-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
|
||||||
|
-- 2. information_schema.columns по listings: ceiling_height отсутствует,
|
||||||
|
-- ceiling_height_m на месте и count(ceiling_height_m) >= 16 147.
|
||||||
|
-- 3. Скрейпинг продолжает писать: count(ceiling_height_m) растёт после сноса.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
|
||||||
|
-- и в .claude/rules/sql.md § lock_timeout.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
ALTER TABLE listings DROP COLUMN IF EXISTS ceiling_height;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN listings.ceiling_height_m IS
|
||||||
|
'Высота потолков, метры. ЕДИНСТВЕННАЯ колонка этого признака (#2699): дубль listings.ceiling_height (019) снесён миграцией 251 после того, как 238 перенесла в неё значения. Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m (гейт правдоподобия 2.0-6.0 м). Читает estimator (comp-scoring #2012).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- 253_scrape_proxy_domclick_affinity_release.sql
|
||||||
|
-- Снять с узла резервацию provider_affinity='domclick' (#2800).
|
||||||
|
--
|
||||||
|
-- WHY (замер, не рассуждение — живая проба 09.08.2026, тракт сайдкар+camoufox,
|
||||||
|
-- POST /fetch на robots.txt рабочего хоста каждой площадки):
|
||||||
|
--
|
||||||
|
-- узел | affinity | avito | ekb.cian.ru | realty.yandex.ru | bff-search-web.domclick.ru
|
||||||
|
-- -----+----------+-------+--------------------+------------------+---------------------------
|
||||||
|
-- 1 | domclick | 200 | 200 «Ошибка — Циан»| 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY
|
||||||
|
-- 9 | any | 200 | 200 | 200 | 200
|
||||||
|
-- 10 | any | 200 | 200 | 200 | 200
|
||||||
|
-- 11 | any | 200 | 200 | 200 | 200
|
||||||
|
--
|
||||||
|
-- Узел, закреплённый 173-й миграцией СПЕЦИАЛЬНО за Домкликом, до рабочего хоста
|
||||||
|
-- Домклика не доходит вообще (NS_ERROR_PROXY_BAD_GATEWAY на bff-search-web —
|
||||||
|
-- именно туда ходит боевой сбор, см. providers/domclick/serp.py::_BFF_BASE), при
|
||||||
|
-- этом Авито и Яндекс через него отвечают штатно. Резервация даёт ровно обратный
|
||||||
|
-- эффект задуманному: единственный источник, которому узел ГОДЕН НЕ БЫЛ, держал его
|
||||||
|
-- за собой, а два источника, которым он годен, его не видели —
|
||||||
|
-- acquire('avito'|'yandex') отбирает по provider_affinity IN (source,'any'), а
|
||||||
|
-- fallback этот узел не берёт (защита последнего узла выделенной affinity).
|
||||||
|
--
|
||||||
|
-- 'any', а НЕ enabled=false: узел жив для двух площадок из четырёх, выключать его
|
||||||
|
-- целиком — терять четверть и без того дефицитного пула (#2638).
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- provider_affinity='domclick' → 'any' для узлов, у которых affinity именно такая.
|
||||||
|
-- CHECK-констрейнт (173) не трогаем: значение 'domclick' остаётся допустимым, если
|
||||||
|
-- в пуле появится узел, который до Домклика реально доходит.
|
||||||
|
--
|
||||||
|
-- ЧТО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ (граница честная):
|
||||||
|
-- Она НЕ чинит Домклик. acquire('domclick') и до неё видел все четыре узла
|
||||||
|
-- (affinity IN ('domclick','any')), т.е. шанс вытянуть узел 1 и потратить первый
|
||||||
|
-- бакет впустую был и остаётся 1/4 — закрывает это проба по паре «узел × источник»
|
||||||
|
-- (#2800 часть B), а не смена affinity. Здесь снимается только резервация.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- Один UPDATE в транзакции; повторный прогон не находит строк (no-op) — auto-apply
|
||||||
|
-- strict на деплое это требует. Блокирующего DDL нет (см.
|
||||||
|
-- scripts/check-migration-lock-timeout.py: правило про ALTER/DROP/CREATE INDEX),
|
||||||
|
-- UPDATE берёт row-lock на единичные строки.
|
||||||
|
--
|
||||||
|
-- Dependencies: 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE scrape_proxies
|
||||||
|
SET provider_affinity = 'any',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE provider_affinity = 'domclick';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
1
tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html
vendored
Normal file
1
tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="//cdn.cian.site/frontend/common-css/l/common.min.css"><title>Ошибка - Циан</title><!-- … инлайн-стили, GTM и аналитика вырезаны: 374168 Б → фрагмент … --><body>ISIDPlhwqgqcowpM8uZOWWzawmWX3JbKNOWV5y8jbBk1QTMxXSbNTCYzv2eY56%2BzgvWctsm1nNNBInjiTIE4wTKyIXqNmtYlOzmhaTK60Tn8LgFw465ooSTZRlZbWNAVRqsSkSbwX3cLXr%2BXama%2BvH5PpteyqjVoMEnZZ9fy7U9Mq6lNhNqPwTg6YASdghUaijPGTLJGRj1xv2zyp3nGfDDlmBEufGFsQSmWpaquZAFvWbMSIVagrgmWV%2FpwZY%2B05W7zk%2B48X94xHfjeo42qibiytt3w1NTImtWX6RAUPZOEWRMJX%2FDAhDnp6PpcNPWJMlIzO54LIEgTLTbPMeT8Zcz8bbz8bbz8bbz8bcxqGIlysW6SCSEHnI3OmWtyI1qNpU%2BaVl4fL4yPXGUHMlzpFO8g7x3nL5nBgMCqbNNM489eVZ8CQV07DTtFVFNVZlZbprTPdHkXK8dpDGUHO2YoUriDzZ%2FPcifCVNlgCap0b6RzwJbvsziCR09wO2bk%2B2Fh0BtH1djPV2M9XYz1djPV2MtsmyQM5f7xfaMjZdAbCdhzBtLjB7V9hsvkNjwjKUT8C9Zw3rBwyJ6wsNWNoze1g2nkHtUNoereRt7INYvs9XYwnf%2FAM6pU5R2c0UDE8gAHwP%2FAIr%2FxAA4EQABAwEFBAgFAgcBAAAAAAABAAIRECExQVGRAxIgYQQiUHGBobHRMDKSwdJA8BMjM0JgguHx%2F9oACAEDAQk%2FAPgbQSMJtW01sTgRy7OMNF5z5BWoJxgYK%2FEdmXus1oN5y2e6cx%2F2aXO7MuF6vCvx76f22I%2FK5vjJ7MxCwVrcimhs440F8Hs0dceYqLMEI7OHeEOqELB2g2Ozf%2FV8hN2QlGQUBC3UIPDu7qIgRhwRu4LdjQ13Ykwt0jzrgmhrcAb%2FABujuW7GivoC52QtPeYuHNbuyH1fiulR3NP5KH8gI%2B5Tf4buZ9wKxHNbqvqBGMrdKEOx4jACsYLh9zQ9QrIff4vOuayqOoNZ9qZ0vRlxvKEqE2EIcLjzXzM%2BDnw3L%2BmDfmeAzFnxedc1lQwSChAGFM6P3T3LbDQo2Zoq0H1WC%2BY%2FBz4f9jlyq0gH4%2FOuayV6Mk%2FuPBCwIRLQVnQwBem7ozdf4DEeK6S7ws9Cukv%2Bo%2B66Q8xhvO96Z1dDsDetsNFtm6LbN0Tt5wvMRTPgM7S7uqIi6ULCjIFLyhB4mzPC2RbwDqAWnxQIGHMZoWBXDzRuaAhNqvRkNN2EhCSiGhbb96LbQB97BhmihZQyBI0KEpiYmIQUEIqf5jssOavNBZhKuoIeL7L62u9RwCQaGokGhoJFLWnDgzKNGx3Zmhmgkug2YQZBPIkRQ2UAA5c6PKedU86oyeC%2BLFa5xv8Aag6oVgHBdiOeYpejaQOHaBhzIn1BXSR9I%2FFdJH0j8V0mQCLA0W%2BQptA05kT6grpE8g0ew4L65mtxV62IIzk%2ByY1nMkzpulHecbzn7dws4OXHlUWLrNOcSPRCLOESCpJm0EjyuQ63C2R3hM9EzzCZ5hM8wmeiEFBYpkjvCZ5j3QgCo3gbcL1s9SPdC1XH9yjvDIkSPRMKYVbOATZaeYTIH21oJB7ls%2FT3Qg1E6LZye8e6EH9C4hPOpTzqU86lPOpTzqUZKyo4jxTifHgcQnnWpTynnWjiE4mjiE86ngMJ51P6hknkmGf8M%2F%2FZ" alt="logo"></a></header><article id="article" class="info"><h1 class="title">Обнаружен подозрительный трафик</h1></article><section class="snippet"><p>Просмотрите возможные причины ошибки по ссылке ниже. Если ошибка повторяется, напишите <a href="mailto:support@cian.ru?subject=Нет доступа к сайту и/или приложению&body=%0D%0A%0D%0A%0D%0A%0D%0AIP-адрес: 46.8.110.92%0D%0AID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366%0D%0AКод страницы: cian_waf_block">в службу поддержки</a> или на почту <a href="mailto:support@cian.ru">support@cian.ru</a>.</p><p>Укажите в письме эти данные:</p><ul><li>IP-адрес: 46.8.110.92</li><li>ID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366</li><li>Код страницы: cian_waf_block</li></ul><script>!function(e,a,t,n){e[n]=e[n]||[],e[n].push({"gtm.start":(new Date).getTime(),event:"gtm.js"});var c=a.getElementsByTagName(t)[0],g=a.createElement(t);g.async=!0,g.src="https://www.googletagmanager.com/gtm.js?id=GTM-KC2KW5",c.parentNode.insertBefore(g,c)}(window,document,"script","dataLayer"),(e=>{"ca"in e||(e.ca=async(...a)=>{e.ca.q.push(a)},e.ca.q=[],e.ca.version="2.0.2-CD-269946.0")})(window),ca("pageview",{category:"page",action:"newpage",label:"open",page:{pageType:"VPNBlock",extra:{antibot:!0}}})</script><noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-KC2KW5" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript><script src="https://static.cdn-cian.ru/frontend/frontend-sdk/analytics/ca.v1.js" data-ab-use-base-url="https://api.cian.ru/ab-use/" data-ebc-analytics-base-url="https://api.cian.ru/ebc-analytics/" data-sopr-base-url="https://sopr-</p></div></body></html>
|
||||||
|
|
@ -387,6 +387,17 @@ class FakeSession:
|
||||||
}
|
}
|
||||||
self.bans.append(ban)
|
self.bans.append(ban)
|
||||||
else:
|
else:
|
||||||
|
# #2803-follow-up: активную строку чужого владельца не перехватываем.
|
||||||
|
# Гейтим по подстроке боевого SQL (как ban-предикаты выше) — иначе мок
|
||||||
|
# реализовал бы защиту сам и тест был бы зелёным на сломанном коде.
|
||||||
|
defends_owner = "scrape_proxy_source_bans.banned_until <= now()" in sql
|
||||||
|
if (
|
||||||
|
defends_owner
|
||||||
|
and ban["banned_until"] > now
|
||||||
|
and ban.get("reason") != p["reason"]
|
||||||
|
and p["reason"] != p.get("live_reason")
|
||||||
|
):
|
||||||
|
return _FakeResult([]) # владельца активной строки не меняем
|
||||||
# эскалация: срок = base * 2^(новый ban_count - 1), потолок max_hours
|
# эскалация: срок = base * 2^(новый ban_count - 1), потолок max_hours
|
||||||
ban["ban_count"] += 1
|
ban["ban_count"] += 1
|
||||||
hours = min(p["base_hours"] * 2 ** (ban["ban_count"] - 1), p["max_hours"])
|
hours = min(p["base_hours"] * 2 ** (ban["ban_count"] - 1), p["max_hours"])
|
||||||
|
|
@ -397,12 +408,18 @@ class FakeSession:
|
||||||
)
|
)
|
||||||
|
|
||||||
if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql:
|
if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql:
|
||||||
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2
|
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2.
|
||||||
|
# Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры
|
||||||
|
# в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и
|
||||||
|
# тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном.
|
||||||
|
filters_reason = "reason = CAST(:only_reason AS text)" in sql
|
||||||
|
only_reason = p.get("only_reason") if filters_reason else None
|
||||||
cleared = [
|
cleared = [
|
||||||
b
|
b
|
||||||
for b in self.bans
|
for b in self.bans
|
||||||
if b["proxy_id"] == p["proxy_id"]
|
if b["proxy_id"] == p["proxy_id"]
|
||||||
and (p["source"] is None or b["source"] == p["source"])
|
and (p["source"] is None or b["source"] == p["source"])
|
||||||
|
and (only_reason is None or b.get("reason") == only_reason)
|
||||||
]
|
]
|
||||||
self.bans = [b for b in self.bans if b not in cleared]
|
self.bans = [b for b in self.bans if b not in cleared]
|
||||||
return _FakeResult([{"source": b["source"]} for b in cleared])
|
return _FakeResult([{"source": b["source"]} for b in cleared])
|
||||||
|
|
@ -413,6 +430,12 @@ class FakeSession:
|
||||||
self.bans = [b for b in self.bans if b["banned_until"] >= cutoff]
|
self.bans = [b for b in self.bans if b["banned_until"] >= cutoff]
|
||||||
return _FakeResult(purged)
|
return _FakeResult(purged)
|
||||||
|
|
||||||
|
if "SELECT reason, banned_until" in sql: # mark_banned: кто держит активный бан пары
|
||||||
|
ban = self._ban(p["proxy_id"], p["source"])
|
||||||
|
if ban is None or ban["banned_until"] <= datetime.now(UTC):
|
||||||
|
return _FakeResult([])
|
||||||
|
return _FakeResult([{"reason": ban.get("reason"), "banned_until": ban["banned_until"]}])
|
||||||
|
|
||||||
if "SELECT enabled, disabled_reason FROM scrape_proxies" in sql: # mark_banned diag read
|
if "SELECT enabled, disabled_reason FROM scrape_proxies" in sql: # mark_banned diag read
|
||||||
row = self._by_id(p["id"])
|
row = self._by_id(p["id"])
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,13 @@ tests/test_data_erasure.py::test_real_erase_by_phone_finds_differently_formatted
|
||||||
tests/test_data_erasure.py::test_real_erase_by_phone_finds_ru_trunk_prefix_variant
|
tests/test_data_erasure.py::test_real_erase_by_phone_finds_ru_trunk_prefix_variant
|
||||||
tests/test_purge_expired_trade_in_data.py::test_real_purge_deletes_only_anonymous_expired_estimates
|
tests/test_purge_expired_trade_in_data.py::test_real_purge_deletes_only_anonymous_expired_estimates
|
||||||
|
|
||||||
|
# Payments retention (PR #2754), deep-review MEDIUM finding 2026-08-06 — тот же
|
||||||
|
# `_live_session()`. Проверяют предполётную проверку purge против реального
|
||||||
|
# retain_until/payments состояния (здоровая оплаченная строка не поднимает
|
||||||
|
# тревогу и не блокирует джобу), не только SQL-текст.
|
||||||
|
tests/test_purge_expired_trade_in_data.py::test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly
|
||||||
|
tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy_paid_row
|
||||||
|
|
||||||
# Диагноз оборванного прогона (#2764) — тот же `_live_session()`. Проверяет, что
|
# Диагноз оборванного прогона (#2764) — тот же `_live_session()`. Проверяет, что
|
||||||
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
|
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
|
||||||
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
|
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
|
||||||
|
|
|
||||||
114
tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py
Normal file
114
tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""#2657: домклик собирал 0 лотов, потому что ходил мимо прокси-пула.
|
||||||
|
|
||||||
|
Прод 06-09.08.2026: `domclick_city_sweep` четыре прогона подряд `failed` с
|
||||||
|
`buckets=0/6, lots=0, errors=1`. Причина найдена не в QRATOR и не в куках, а в двух
|
||||||
|
сложившихся дефектах:
|
||||||
|
|
||||||
|
1. **Домклик был единственным sweep-источником без `proxy_provider`** (#2160 P4 wiring
|
||||||
|
gap). `build_browser_fetcher(config, "domclick")` без провайдера → lease не берётся →
|
||||||
|
сайдкар идёт через статический `SCRAPER_PROXY_URL`. Этот адрес (прод
|
||||||
|
`scrape_proxies.id=1`, residential, `provider_affinity='domclick'`) с 06.08 отдаёт
|
||||||
|
`NS_ERROR_PROXY_BAD_GATEWAY` ИМЕННО на `bff-search-web.domclick.ru`. Живая проверка
|
||||||
|
09.08 через сайдкар: тот же URL через id=1 → 500, через мобильный узел пула
|
||||||
|
(`id=10`) → 200 и `{"offersCount":704,"snippetsCount":678}` в бакете `st`.
|
||||||
|
Предпосылка миграции 173 («QRATOR банит все прокси кроме этого residential»)
|
||||||
|
опровергнута: мобильный узел домкликовский BFF пускает.
|
||||||
|
|
||||||
|
2. **Транспортная ошибка убивала весь свип на первом бакете.** `fetch_city` ловил
|
||||||
|
только `ValueError/TypeError` — ошибки РАЗБОРА. `httpx.HTTPStatusError` от сайдкара
|
||||||
|
пролетал наружу мимо цикла по `ROOM_BUCKETS`, и прогон заканчивался с `0/6`. Это же
|
||||||
|
не давало сработать ротации lease после N подряд провалов
|
||||||
|
(`browser_fetcher._LEASE_ROTATE_AFTER_FAILS`) — до неё не доживали.
|
||||||
|
|
||||||
|
Фальсификация на старом коде: `test_scraper_leases_proxy_for_domclick` падает
|
||||||
|
`TypeError: unexpected keyword argument 'proxy_provider'`,
|
||||||
|
`test_transport_error_does_not_kill_the_whole_sweep` падает на пробросе
|
||||||
|
`HTTPStatusError` наружу.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.orchestration.scheduler import _job_domclick_city_sweep
|
||||||
|
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
browser_http_endpoint="http://x:9000",
|
||||||
|
use_proxy_pool_browser=True,
|
||||||
|
environment="production",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _no_browser() -> Any:
|
||||||
|
"""Сайдкар не поднимается — нас интересует только то, ЧТО ему передали."""
|
||||||
|
fetcher = MagicMock()
|
||||||
|
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
||||||
|
fetcher.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
with patch("scraper_kit.providers._base.BrowserFetcher", return_value=fetcher) as bf:
|
||||||
|
yield bf
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scraper_leases_proxy_for_domclick(_no_browser: MagicMock) -> None:
|
||||||
|
"""Узел берётся из пула под именем "domclick" — а не из env сайдкара."""
|
||||||
|
provider = MagicMock()
|
||||||
|
scraper = DomClickScraper(_config(), proxy_provider=provider)
|
||||||
|
with patch.object(DomClickScraper, "_sweep_bucket", AsyncMock(return_value=None)):
|
||||||
|
await scraper.fetch_city(city_id=4)
|
||||||
|
|
||||||
|
assert _no_browser.call_args.kwargs["proxy_provider"] is provider
|
||||||
|
assert _no_browser.call_args.kwargs["use_pool"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transport_error_does_not_kill_the_whole_sweep(_no_browser: MagicMock) -> None:
|
||||||
|
"""Отказ сайдкара на первом бакете не обнуляет прогон — остальные бакеты идут.
|
||||||
|
|
||||||
|
Прод-форма отказа: 500 от tradein-browser с NS_ERROR_PROXY_BAD_GATEWAY в теле.
|
||||||
|
"""
|
||||||
|
scraper = DomClickScraper(_config())
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
async def _sweep(self: DomClickScraper, **_: object) -> None:
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"Server error '500 Internal Server Error' | tradein-browser: "
|
||||||
|
'{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}',
|
||||||
|
request=MagicMock(),
|
||||||
|
response=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(DomClickScraper, "_sweep_bucket", _sweep):
|
||||||
|
await scraper.fetch_city(city_id=4)
|
||||||
|
|
||||||
|
assert calls["n"] == len(ROOM_BUCKETS)
|
||||||
|
# Битый бакет НЕ засчитан в охват (#2670) — прогон честно неполон.
|
||||||
|
assert scraper.buckets_completed == len(ROOM_BUCKETS) - 1
|
||||||
|
assert scraper.fetch_errors == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduler_passes_pool_to_domclick_job() -> None:
|
||||||
|
"""Планировщик отдаёт домклику ctx.proxy_provider — как всем остальным свипам."""
|
||||||
|
provider = MagicMock()
|
||||||
|
ctx = SimpleNamespace(
|
||||||
|
config=_config(),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
shutdown_requested=lambda: False,
|
||||||
|
proxy_provider=provider,
|
||||||
|
)
|
||||||
|
sweep = AsyncMock(return_value=None)
|
||||||
|
with patch("scraper_kit.orchestration.scheduler.run_domclick_city_sweep", sweep):
|
||||||
|
await _job_domclick_city_sweep(MagicMock(), 1, {}, ctx) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert sweep.await_args.kwargs["proxy_provider"] is provider
|
||||||
|
|
@ -191,7 +191,7 @@ class _FakeFetcher:
|
||||||
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"scraper_kit.providers._base.build_browser_fetcher",
|
"scraper_kit.providers._base.build_browser_fetcher",
|
||||||
lambda config, source: _FakeFetcher(),
|
lambda config, source, **_kw: _FakeFetcher(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,27 +260,35 @@ async def test_healthcheck_marks_unfit_when_http_green_browser_red(
|
||||||
row = db._by_id(1)
|
row = db._by_id(1)
|
||||||
assert row["browser_unfit_since"] is not None
|
assert row["browser_unfit_since"] is not None
|
||||||
assert row["enabled"] is True and row["consecutive_fails"] == 0
|
assert row["enabled"] is True and row["consecutive_fails"] == 0
|
||||||
assert len(calls) == 2
|
# За такт узел опрашивается по КАЖДОЙ обслуживаемой площадке (#2800), а каждый
|
||||||
|
# транспортный отказ ещё и подтверждается повтором: 4 источника × 2 попытки за
|
||||||
|
# прогон. Узловой вердикт по-прежнему один на прогон — browser_checked == 1 выше.
|
||||||
|
assert len(calls) == 2 * 4 * 2
|
||||||
|
|
||||||
|
|
||||||
async def test_healthcheck_browser_probe_respects_slow_tick(
|
async def test_healthcheck_browser_probe_respects_slow_tick(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет."""
|
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.
|
||||||
|
|
||||||
|
Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на
|
||||||
|
число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой
|
||||||
|
по-прежнему один на BROWSER_PROBE_MINUTES.
|
||||||
|
"""
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
_patch_probes(monkeypatch, calls=calls)
|
_patch_probes(monkeypatch, calls=calls)
|
||||||
db = FakeSession([_proxy(1)])
|
db = FakeSession([_proxy(1)])
|
||||||
|
|
||||||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
assert len(calls) == 1
|
assert len(calls) == 4
|
||||||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox"
|
assert len(calls) == 4, "браузерная проба обязана идти реже ipify — она стоит camoufox"
|
||||||
|
|
||||||
db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta(
|
db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta(
|
||||||
minutes=BROWSER_PROBE_MINUTES + 1
|
minutes=BROWSER_PROBE_MINUTES + 1
|
||||||
)
|
)
|
||||||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
assert len(calls) == 2
|
assert len(calls) == 8
|
||||||
|
|
||||||
|
|
||||||
async def test_healthcheck_skips_browser_probe_when_http_dead(
|
async def test_healthcheck_skips_browser_probe_when_http_dead(
|
||||||
|
|
|
||||||
231
tradein-mvp/backend/tests/test_2767_cian_waf_block.py
Normal file
231
tradein-mvp/backend/tests/test_2767_cian_waf_block.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""#2767 (вторая итерация) — то, что 8 суток принимали за смену разметки, было блоком.
|
||||||
|
|
||||||
|
Живая проба 2026-08-09 (сайдкар → camoufox → узел пула, тот же тракт, что у задачи)
|
||||||
|
на одной и той же странице `zhk-pihtovyy-ekb-i.cian.ru`:
|
||||||
|
|
||||||
|
узел #1 asocks-residential-1 → 374 168 Б, cian_waf_block, initialState нет
|
||||||
|
узел #9 asocks-mobile-1 → 542 021 Б, initialState ЕСТЬ
|
||||||
|
узел #10 asocks-mobile-2 → 557 235 Б, initialState ЕСТЬ
|
||||||
|
узел #11 asocks-mobile-3 → 557 163 Б, initialState ЕСТЬ
|
||||||
|
|
||||||
|
Разметка не менялась: ТЕКУЩИЙ разбор находит состояние на трёх узлах из четырёх.
|
||||||
|
Ломался тракт: `fetch_newbuilding` была единственным cian-путём без proxy_provider,
|
||||||
|
поэтому всегда выходила через один env-узел сайдкара — как раз тот, чей exit-IP
|
||||||
|
Циан забанил.
|
||||||
|
|
||||||
|
Фикстура `cian_waf_block_zhk_page.html` — НАСТОЯЩИЙ ответ прода (обрезаны инлайн-стили
|
||||||
|
и часть аналитики; текст блока, код страницы и разметка сохранены байт в байт).
|
||||||
|
|
||||||
|
Все три проверки красные на коде до правки:
|
||||||
|
1. страница блока опознаётся (старый список маркеров отвечал на неё `none`);
|
||||||
|
2. блок репортится в пул как бан узла (старый код не мог: lease не брался вовсе);
|
||||||
|
3. proxy_provider доезжает до фабрики фетчера (старый код такого аргумента не знал).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
sys.modules.setdefault("weasyprint", MagicMock())
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
|
||||||
|
_blocked_by,
|
||||||
|
_describe_parse_miss,
|
||||||
|
fetch_newbuilding,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FIXTURE = Path(__file__).parent / "fixtures" / "cian_waf_block_zhk_page.html"
|
||||||
|
|
||||||
|
# Список маркеров ДО правки (#2768). Держим здесь дословно: тест ниже показывает, что
|
||||||
|
# страница блока не содержала ни одного из них — то есть «antibot_markers=none» был не
|
||||||
|
# «защиты нет», а «список не знал этой подписи».
|
||||||
|
_MARKERS_BEFORE_FIX = (
|
||||||
|
"captcha",
|
||||||
|
"qrator",
|
||||||
|
"ddos-guard",
|
||||||
|
"cf-chl",
|
||||||
|
"доступ ограничен",
|
||||||
|
"вы не робот",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def waf_html() -> str:
|
||||||
|
return _FIXTURE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_waf_page_carries_none_of_the_old_markers(waf_html: str) -> None:
|
||||||
|
"""Почему правка вообще нужна: прежний список на этой странице молчал.
|
||||||
|
|
||||||
|
Молчание защиты читалось в журнале как её вердикт «чисто» — и увело разбор
|
||||||
|
в гипотезу «Циан сменил разметку» на восемь суток.
|
||||||
|
"""
|
||||||
|
lowered = waf_html.lower()
|
||||||
|
assert [m for m in _MARKERS_BEFORE_FIX if m in lowered] == []
|
||||||
|
# При этом страница прямо называет себя блоком — и словами, и кодом, и в аналитике.
|
||||||
|
assert "cian_waf_block" in waf_html
|
||||||
|
assert "Обнаружен подозрительный трафик" in waf_html
|
||||||
|
assert 'pageType:"VPNBlock"' in waf_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_parse_miss_names_the_waf_block(waf_html: str) -> None:
|
||||||
|
"""Строка отказа обязана НАЗВАТЬ блок. Красная на старом коде: там `none`."""
|
||||||
|
described = _describe_parse_miss(waf_html)
|
||||||
|
assert "cian_waf_block" in described
|
||||||
|
assert "antibot_markers=none" not in described
|
||||||
|
assert f"html_len={len(waf_html)}" in described
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_by_stays_silent_on_a_normal_card() -> None:
|
||||||
|
"""Ложных срабатываний быть не должно: обычная разметка ЖК блоком не считается."""
|
||||||
|
assert _blocked_by("<html><body>ЖК «Пихтовый», сдача 2027</body></html>") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _spy_fetcher(html: str) -> MagicMock:
|
||||||
|
fetcher = MagicMock()
|
||||||
|
fetcher.fetch = AsyncMock(return_value=html)
|
||||||
|
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
||||||
|
fetcher.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
return fetcher
|
||||||
|
|
||||||
|
|
||||||
|
# Здоровая карточка ЖК в текущем формате Циана (.concat([...])), и на ней ЕСТЬ подстрока
|
||||||
|
# «captcha»: обычная страница грузит скрипт SmartCaptcha. Форма взята с живой страницы
|
||||||
|
# 2026-08-09, содержимое сокращено до того, что читает fetch_newbuilding.
|
||||||
|
_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT = (
|
||||||
|
'<html><head><script src="https://smartcaptcha.yandexcloud.net/captcha.js" async>'
|
||||||
|
"</script></head><body><script>"
|
||||||
|
"window._cianConfig['newbuilding-card-desktop-frontend'] = "
|
||||||
|
"(window._cianConfig['newbuilding-card-desktop-frontend'] || []).concat("
|
||||||
|
'[{"key":"initialState","value":{"newbuilding":{"id":108855,"name":"ЖК «Пихтовый»"}}}]'
|
||||||
|
");</script></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None:
|
||||||
|
"""Блок → `report_ban`, пока lease ещё жив.
|
||||||
|
|
||||||
|
Без этого пул продолжает выдавать сожжённый узел, и следующий дом идёт с того же
|
||||||
|
IP — ровно то, что происходило 200 попыток подряд.
|
||||||
|
"""
|
||||||
|
fetcher = _spy_fetcher(waf_html)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"scraper_kit.providers.cian.newbuilding.build_browser_fetcher",
|
||||||
|
MagicMock(return_value=fetcher),
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
|
||||||
|
result = await fetch_newbuilding(
|
||||||
|
"https://zhk-pihtovyy-ekb-i.cian.ru",
|
||||||
|
config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
fetcher.report_ban.assert_called_once()
|
||||||
|
assert "cian_waf_block" in fetcher.report_ban.call_args.args[0]
|
||||||
|
# Бан репортится ДО выхода из контекста — иначе lease уже отпущен и вызов no-op.
|
||||||
|
assert fetcher.__aexit__.await_count == 1
|
||||||
|
assert "cian_waf_block" in "\n".join(r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parsed_page_never_bans_the_node_even_with_captcha_word(monkeypatch) -> None:
|
||||||
|
"""Разобранная страница НЕ банит узел, даже если на ней написано «captcha».
|
||||||
|
|
||||||
|
Живая регрессия 2026-08-09: первая версия этой правки звала `_blocked_by` на КАЖДОМ
|
||||||
|
ответе, и за первые 17 секунд прогона забанила два ЗДОРОВЫХ узла (9 и 10) на
|
||||||
|
страницах, которые успешно разобрались — обычная карточка ЖК грузит скрипт
|
||||||
|
SmartCaptcha, подстрока там есть всегда. Признак «есть подпись защиты» осмыслен
|
||||||
|
только вместе с «состояния нет»: список маркеров создавался объяснять УЖЕ
|
||||||
|
случившийся отказ разбора, а не служить самостоятельным детектором.
|
||||||
|
"""
|
||||||
|
fetcher = _spy_fetcher(_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"scraper_kit.providers.cian.newbuilding.build_browser_fetcher",
|
||||||
|
MagicMock(return_value=fetcher),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_newbuilding(
|
||||||
|
"https://zhk-pihtovyy-ekb-i.cian.ru",
|
||||||
|
config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Предпосылка теста: страница действительно и разбирается, и содержит слово-ловушку.
|
||||||
|
assert result is not None
|
||||||
|
assert result.cian_internal_house_id == 108855
|
||||||
|
assert "captcha" in _HEALTHY_CARD_WITH_CAPTCHA_SCRIPT
|
||||||
|
fetcher.report_ban.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) -> None:
|
||||||
|
"""Провод целиком: provider из задачи → build_browser_fetcher → пул.
|
||||||
|
|
||||||
|
Красный на старом коде дважды: аргумента `proxy_provider` там не было, и фабрика
|
||||||
|
для этого провайдера не вызывалась вовсе (BrowserFetcher конструировался вручную,
|
||||||
|
без пула).
|
||||||
|
"""
|
||||||
|
factory = MagicMock(return_value=_spy_fetcher(waf_html))
|
||||||
|
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_browser_fetcher", factory)
|
||||||
|
provider = object()
|
||||||
|
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000")
|
||||||
|
|
||||||
|
await fetch_newbuilding(
|
||||||
|
"https://zhk-pihtovyy-ekb-i.cian.ru", config=config, proxy_provider=provider
|
||||||
|
)
|
||||||
|
|
||||||
|
factory.assert_called_once_with(config, "cian", proxy_provider=provider)
|
||||||
|
|
||||||
|
|
||||||
|
# ── новый фронт Циана: тот же ключ под другим именем MFE ──────────────────────
|
||||||
|
|
||||||
|
# Форма живой страницы zhk-kosmos-ekb-i.cian.ru (2026-08-09): состояние лежит под
|
||||||
|
# 'newbuilding-card-desktop-fichering-frontend', СТАРОГО имени на странице нет вовсе.
|
||||||
|
_FICHERING_CARD = (
|
||||||
|
"<html><body><script>"
|
||||||
|
"window._cianConfig['newbuilding-card-desktop-fichering-frontend'] = "
|
||||||
|
"(window._cianConfig['newbuilding-card-desktop-fichering-frontend'] || []).concat("
|
||||||
|
'[{"key":"initialState","value":{"newbuilding":{"id":3235089,"name":"Космос"},'
|
||||||
|
'"realtyValuation":{"data":{"priceDynamics":{"chart":{"data":'
|
||||||
|
'{"labels":["2026-06-01","2026-07-01"],"values":[4100000,4250000]}}}}}}}]'
|
||||||
|
");</script></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_state_is_found_under_the_renamed_fichering_mfe(monkeypatch) -> None:
|
||||||
|
"""ЖК на новом фронте Циана разбирается — вместе с графиком цен.
|
||||||
|
|
||||||
|
Именно из-за этого имени `houses_price_dynamics` стояла с 2026-07-26: страница
|
||||||
|
приходила целиком, состояние в ней было, но под другим MFE — и отказ выглядел как
|
||||||
|
«разметка изменилась», хотя форма состояния та же.
|
||||||
|
"""
|
||||||
|
fetcher = _spy_fetcher(_FICHERING_CARD)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"scraper_kit.providers.cian.newbuilding.build_browser_fetcher",
|
||||||
|
MagicMock(return_value=fetcher),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_newbuilding(
|
||||||
|
"https://zhk-kosmos-ekb-i.cian.ru",
|
||||||
|
config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.cian_internal_house_id == 3235089
|
||||||
|
assert result.name == "Космос"
|
||||||
|
# График — тот самый, ради которого задача и существует.
|
||||||
|
assert [p["month_date"] for p in result.realty_valuation_chart] == [
|
||||||
|
"2026-06-01",
|
||||||
|
"2026-07-01",
|
||||||
|
]
|
||||||
|
assert [p["price_per_sqm"] for p in result.realty_valuation_chart] == [4100000.0, 4250000.0]
|
||||||
|
fetcher.report_ban.assert_not_called()
|
||||||
|
|
@ -53,15 +53,19 @@ def test_describe_parse_miss_reports_size_and_no_markers() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_describe_parse_miss_names_found_markers() -> None:
|
def test_describe_parse_miss_names_found_markers() -> None:
|
||||||
"""Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного."""
|
"""Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного.
|
||||||
|
|
||||||
|
Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть
|
||||||
|
на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после
|
||||||
|
того, как забанила здоровые узлы (#2767).
|
||||||
|
"""
|
||||||
described = _describe_parse_miss("<html><div class='g-recaptcha'>Вы не робот?</div></html>")
|
described = _describe_parse_miss("<html><div class='g-recaptcha'>Вы не робот?</div></html>")
|
||||||
assert "captcha" in described
|
|
||||||
assert "вы не робот" in described
|
assert "вы не робот" in described
|
||||||
assert "html_len=" in described
|
assert "html_len=" in described
|
||||||
|
|
||||||
|
|
||||||
def _spy_browser_fetcher(html: str) -> MagicMock:
|
def _spy_browser_fetcher(html: str) -> MagicMock:
|
||||||
"""BrowserFetcher-подмена: async-CM, чей .fetch() отдаёт заданный html."""
|
"""Подмена фабрики фетчера: async-CM, чей .fetch() отдаёт заданный html."""
|
||||||
fetcher = MagicMock()
|
fetcher = MagicMock()
|
||||||
fetcher.fetch = AsyncMock(return_value=html)
|
fetcher.fetch = AsyncMock(return_value=html)
|
||||||
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
||||||
|
|
@ -71,17 +75,24 @@ def _spy_browser_fetcher(html: str) -> MagicMock:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None:
|
async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None:
|
||||||
"""Отказ разбора обязан писать РАЗМЕР полученной страницы — то самое число, которое
|
"""Отказ разбора обязан писать РАЗМЕР полученной страницы.
|
||||||
разделяет «изменилась разметка» и «защитная заглушка» (иначе log бесполезен)."""
|
|
||||||
|
Размер один гипотезы уже НЕ разделяет (страница блока Циана весит 374 КБ — см.
|
||||||
|
test_2767_cian_waf_block.py), но остаётся обязательным фактом в строке отказа.
|
||||||
|
"""
|
||||||
shell = "<html><body>Доступ ограничен</body></html>" # короткая заглушка
|
shell = "<html><body>Доступ ограничен</body></html>" # короткая заглушка
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"scraper_kit.providers.cian.newbuilding.BrowserFetcher", _spy_browser_fetcher(shell)
|
"scraper_kit.providers.cian.newbuilding.build_browser_fetcher",
|
||||||
|
_spy_browser_fetcher(shell),
|
||||||
)
|
)
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
|
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
|
||||||
result = await fetch_newbuilding(
|
result = await fetch_newbuilding(
|
||||||
"https://zhk-tihiy-centr-ekb-i.cian.ru",
|
"https://zhk-tihiy-centr-ekb-i.cian.ru",
|
||||||
config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
|
config=SimpleNamespace(
|
||||||
|
browser_http_endpoint="http://tradein-browser:3000",
|
||||||
|
use_proxy_pool_browser=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
|
||||||
456
tradein-mvp/backend/tests/test_2800_per_source_probe.py
Normal file
456
tradein-mvp/backend/tests/test_2800_per_source_probe.py
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ.
|
||||||
|
|
||||||
|
Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь —
|
||||||
|
верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный
|
||||||
|
ответ означал «узел годен для Авито», а читался как «узел годен вообще».
|
||||||
|
|
||||||
|
Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация):
|
||||||
|
|
||||||
|
узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru
|
||||||
|
-----+----------+-------+---------------------+--------------+---------------------------
|
||||||
|
1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY
|
||||||
|
10 | any | 200 | 200 | 200 | 200
|
||||||
|
|
||||||
|
Что сторожится (каждый тест ниже падает на коде до фикса):
|
||||||
|
|
||||||
|
1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для
|
||||||
|
Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')`
|
||||||
|
выдавал его как ни в чём не бывало.
|
||||||
|
2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен
|
||||||
|
(`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной).
|
||||||
|
3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех.
|
||||||
|
4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и
|
||||||
|
забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что
|
||||||
|
«дешёвая ipify стирает браузерный вердикт» в #2723).
|
||||||
|
5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой
|
||||||
|
вердикт #2723 сохранён).
|
||||||
|
6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс).
|
||||||
|
7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import scraper_kit.browser_fetcher as bf
|
||||||
|
|
||||||
|
from app.services import proxy_pool
|
||||||
|
from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release
|
||||||
|
from tests.services.test_proxy_pool import FakeSession, _proxy
|
||||||
|
|
||||||
|
# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным.
|
||||||
|
_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = {
|
||||||
|
("http://u:p@h1:8080", "domclick"): (
|
||||||
|
False,
|
||||||
|
"proxy",
|
||||||
|
'{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_probes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
matrix: dict[tuple[str, str], tuple[bool, str | None, str]],
|
||||||
|
calls: list[tuple[str, str]] | None = None,
|
||||||
|
*,
|
||||||
|
default: tuple[bool, str | None, str] = (True, None, "html_len=16477"),
|
||||||
|
) -> None:
|
||||||
|
"""ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник)."""
|
||||||
|
|
||||||
|
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
|
||||||
|
return True, "1.2.3.4", 10, None
|
||||||
|
|
||||||
|
async def _fake_browser(
|
||||||
|
endpoint: str, proxy_url: str, **kw: Any
|
||||||
|
) -> tuple[bool, str | None, str]:
|
||||||
|
source = str(kw.get("source", "avito"))
|
||||||
|
if calls is not None:
|
||||||
|
calls.append((proxy_url, source))
|
||||||
|
return matrix.get((proxy_url, source), default)
|
||||||
|
|
||||||
|
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
|
||||||
|
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
|
||||||
|
monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. главное: вердикт разведён по источникам ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_node_dead_for_domclick_is_not_issued_to_domclick(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём.
|
||||||
|
|
||||||
|
Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при
|
||||||
|
`browser_fail_streak=0` и свежей пробе.
|
||||||
|
"""
|
||||||
|
_patch_probes(monkeypatch, _LIVE_MATRIX)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Домклику достаётся только исправный узел…
|
||||||
|
first = acquire(db, "domclick") # type: ignore[arg-type]
|
||||||
|
assert first is not None and first.id == 2
|
||||||
|
|
||||||
|
# …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить
|
||||||
|
# заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой
|
||||||
|
# заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0.
|
||||||
|
assert acquire(db, "domclick") is None, ( # type: ignore[arg-type]
|
||||||
|
"Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла.
|
||||||
|
lease = acquire(db, "avito") # type: ignore[arg-type]
|
||||||
|
assert lease is not None and lease.id == 1
|
||||||
|
|
||||||
|
ban = db._ban(1, "domclick")
|
||||||
|
assert ban is not None
|
||||||
|
assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба"
|
||||||
|
assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None
|
||||||
|
assert counters["pair_banned"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_one_dead_platform_does_not_stain_the_node_globally(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает."""
|
||||||
|
_patch_probes(monkeypatch, _LIVE_MATRIX)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
row = db._by_id(1)
|
||||||
|
assert row["browser_unfit_since"] is None
|
||||||
|
assert row["browser_fail_streak"] == 0
|
||||||
|
assert row["enabled"] is True and row["consecutive_fails"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел)."""
|
||||||
|
dead_everywhere = {
|
||||||
|
("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable")
|
||||||
|
for src in ("avito", "cian", "yandex", "domclick")
|
||||||
|
}
|
||||||
|
_patch_probes(monkeypatch, dead_everywhere)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
for _ in range(BROWSER_UNFIT_THRESHOLD):
|
||||||
|
db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert db._by_id(1)["browser_unfit_since"] is not None
|
||||||
|
assert db._by_id(2)["browser_unfit_since"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("source", "must_contain"),
|
||||||
|
[
|
||||||
|
("avito", "www.avito.ru"),
|
||||||
|
("cian", "ekb.cian.ru"),
|
||||||
|
("yandex", "realty.yandex.ru"),
|
||||||
|
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
|
||||||
|
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
|
||||||
|
("domclick", "bff-search-web.domclick.ru"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_probe_asks_the_working_host_of_each_source(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
|
||||||
|
) -> None:
|
||||||
|
seen: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status_code = 200
|
||||||
|
text = '{"html": "<pre>User-agent: *</pre>"}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json() -> dict[str, str]:
|
||||||
|
return {"html": "<pre>User-agent: *</pre>"}
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, **_kw: Any) -> None: ...
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _Client:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
|
||||||
|
seen["payload"] = json
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
|
||||||
|
ok, _fail_kind, _detail = await bf.probe_proxy_via_browser(
|
||||||
|
"http://tradein-browser:3000", "http://u:p@node:8080", source=source
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert must_contain in seen["payload"]["url"]
|
||||||
|
assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет
|
||||||
|
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
|
||||||
|
assert seen["payload"]["source"] == "generic"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы."""
|
||||||
|
stub = "<!DOCTYPE html><html><head><title>Ошибка - Циан</title></head><body>…</body></html>"
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status_code = 200
|
||||||
|
text = "{}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json() -> dict[str, str]:
|
||||||
|
return {"html": stub}
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, **_kw: Any) -> None: ...
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _Client:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
|
||||||
|
ok, fail_kind, detail = await bf.probe_proxy_via_browser(
|
||||||
|
"http://tradein-browser:3000", "http://u:p@node:8080", source="cian"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
|
||||||
|
assert fail_kind == "page"
|
||||||
|
assert "not robots.txt" in detail
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя."""
|
||||||
|
_patch_probes(
|
||||||
|
monkeypatch,
|
||||||
|
{("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")},
|
||||||
|
)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert db._ban(1, "cian") is not None
|
||||||
|
assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. проба снимает только свою строку ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором.
|
||||||
|
|
||||||
|
robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить
|
||||||
|
дорогой вердикт значило бы повторить #2723 на паре.
|
||||||
|
"""
|
||||||
|
later = datetime.now(UTC) + timedelta(hours=6)
|
||||||
|
db = FakeSession(
|
||||||
|
[_proxy(1), _proxy(2)],
|
||||||
|
bans=[
|
||||||
|
{
|
||||||
|
"proxy_id": 1,
|
||||||
|
"source": "avito",
|
||||||
|
"banned_until": later,
|
||||||
|
"ban_count": 1,
|
||||||
|
"reason": "banned:avito", # распознан боевым сбором (капча/QRATOR)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"proxy_id": 1,
|
||||||
|
"source": "cian",
|
||||||
|
"banned_until": later,
|
||||||
|
"ban_count": 1,
|
||||||
|
"reason": "probe:browser", # прошлый вердикт самой пробы
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_patch_probes(monkeypatch, {}) # все площадки отвечают
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
avito = db._ban(1, "avito")
|
||||||
|
assert avito is not None, "чужой бан проба снимать не имеет права"
|
||||||
|
assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать"
|
||||||
|
assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять"
|
||||||
|
assert counters["pair_cleared"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4b. …и не присваивает чужую (дефект #2803, реализовался на проде) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_does_not_steal_a_live_ban(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Упавшая проба НЕ переписывает активный бан, поставленный боевым сбором.
|
||||||
|
|
||||||
|
Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21` после
|
||||||
|
упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. Фильтр «снимаю только
|
||||||
|
своё» при этом цел, но защищать перестаёт: присвоенная строка уже «своя», и
|
||||||
|
следующая успешная проба сняла бы ею бан, который площадка поставила по-настоящему.
|
||||||
|
Плюс сама метка перестаёт быть свидетельством («нас отбили» неотличимо от «мы не
|
||||||
|
смогли», #2764), а ban_count складывает события разного рода в одну эскалацию —
|
||||||
|
отдых пары вырос с 6 ч до 12 ч.
|
||||||
|
"""
|
||||||
|
until = datetime.now(UTC) + timedelta(hours=6)
|
||||||
|
db = FakeSession(
|
||||||
|
[_proxy(1), _proxy(2)],
|
||||||
|
bans=[
|
||||||
|
{
|
||||||
|
"proxy_id": 1,
|
||||||
|
"source": "cian",
|
||||||
|
"banned_until": until,
|
||||||
|
"ban_count": 1,
|
||||||
|
"reason": "banned:cian", # боевой сбор: Циан отдал заглушку
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_patch_probes(
|
||||||
|
monkeypatch,
|
||||||
|
{("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")},
|
||||||
|
)
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
ban = db._ban(1, "cian")
|
||||||
|
assert ban is not None
|
||||||
|
assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора"
|
||||||
|
assert ban["ban_count"] == 1, "два события разного рода посчитаны одной эскалацией"
|
||||||
|
assert ban["banned_until"] == until, "чужой срок проба не пересчитывает (может и укоротить)"
|
||||||
|
assert counters["pair_banned"] == 0, "счётчик не должен объявлять баном то, чего не записал"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_live_ban_takes_over_the_probe_row(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Зеркало намеренно НЕ симметрично: боевой сбор строку пробы перехватывает.
|
||||||
|
|
||||||
|
Его вердикт сильнее — площадка отбила нас именно сейчас, — пара остаётся забаненной,
|
||||||
|
а метка становится точнее. Если запретить и ему, строка останется за пробой, и её же
|
||||||
|
зелёный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально.
|
||||||
|
"""
|
||||||
|
db = FakeSession(
|
||||||
|
[_proxy(1), _proxy(2)],
|
||||||
|
bans=[
|
||||||
|
{
|
||||||
|
"proxy_id": 1,
|
||||||
|
"source": "cian",
|
||||||
|
"banned_until": datetime.now(UTC) + timedelta(hours=6),
|
||||||
|
"ban_count": 1,
|
||||||
|
"reason": "probe:browser",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_pool.mark_banned(db, 1, source="cian") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert db._ban(1, "cian")["reason"] == "banned:cian"
|
||||||
|
|
||||||
|
# …и с этой минуты зелёная проба его не снимет — ради чего перехват и нужен.
|
||||||
|
_patch_probes(monkeypatch, {})
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
assert db._ban(1, "cian") is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5-6. чужие отказы ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Лежащий сайдкар не должен забанить разом все пары (#2686-класс)."""
|
||||||
|
down = {
|
||||||
|
(f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError")
|
||||||
|
for pid in (1, 2)
|
||||||
|
for src in ("avito", "cian", "yandex", "domclick")
|
||||||
|
}
|
||||||
|
_patch_probes(monkeypatch, down)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert counters["pair_banned"] == 0
|
||||||
|
assert db.bans == []
|
||||||
|
assert db._by_id(1)["browser_unfit_since"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. цена такта ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_cost_is_nodes_times_servable_sources(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Крест ограничен источниками, которым узел вообще может достаться.
|
||||||
|
|
||||||
|
Диагностика не должна превращаться в нагрузку: узел с выделенной affinity
|
||||||
|
спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника
|
||||||
|
= 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на
|
||||||
|
площадку в сутки против ~1000 боевых /fetch.
|
||||||
|
"""
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
_patch_probes(monkeypatch, {}, calls)
|
||||||
|
db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")])
|
||||||
|
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [
|
||||||
|
"avito",
|
||||||
|
"cian",
|
||||||
|
"domclick",
|
||||||
|
"yandex",
|
||||||
|
]
|
||||||
|
assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"]
|
||||||
|
assert len(calls) == 5
|
||||||
|
|
||||||
|
|
||||||
|
async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки.
|
||||||
|
|
||||||
|
Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из
|
||||||
|
теста выше не меняется, пока всё зелено.
|
||||||
|
"""
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
_patch_probes(monkeypatch, _LIVE_MATRIX, calls)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert calls.count(("http://u:p@h1:8080", "domclick")) == 2
|
||||||
|
assert calls.count(("http://u:p@h1:8080", "avito")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Один провал, второй заход зелёный → бан пары не пишется."""
|
||||||
|
attempts: dict[str, int] = {}
|
||||||
|
|
||||||
|
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
|
||||||
|
return True, "1.2.3.4", 10, None
|
||||||
|
|
||||||
|
async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]:
|
||||||
|
source = str(kw.get("source", "avito"))
|
||||||
|
key = f"{proxy_url}|{source}"
|
||||||
|
attempts[key] = attempts.get(key, 0) + 1
|
||||||
|
if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1:
|
||||||
|
return False, "proxy", "503 browser unavailable"
|
||||||
|
return True, None, "html_len=150"
|
||||||
|
|
||||||
|
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
|
||||||
|
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
|
||||||
|
monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert counters["pair_banned"] == 0
|
||||||
|
assert db._ban(1, "domclick") is None
|
||||||
|
lease = acquire(db, "domclick") # type: ignore[arg-type]
|
||||||
|
assert lease is not None
|
||||||
|
release(db, lease.id) # type: ignore[arg-type]
|
||||||
|
|
@ -839,13 +839,31 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
|
||||||
len(probe_latencies) >= 10
|
len(probe_latencies) >= 10
|
||||||
), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
|
), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
|
||||||
|
|
||||||
# 2. ТЕМП ограничен. Флуд предлагал тысячи попыток в секунду — до bcrypt их
|
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
|
||||||
# доехало не больше потолка (запас ×1.5 на планировщик).
|
# потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик).
|
||||||
assert len(codes) > connections, (
|
max_attempts_per_s = ceiling_per_s * 1.5
|
||||||
"флуд не состоялся: на каждое соединение вышло не больше одного ответа — "
|
offered_per_s = len(codes) / elapsed
|
||||||
"мерить потолок не на чем"
|
|
||||||
|
# ПРЕДУСЛОВИЕ, и оно отделено от вердикта намеренно: «нагрузку создать не
|
||||||
|
# удалось» и «потолок не работает» — разные новости, и красный обязан их
|
||||||
|
# различать. Порог здесь ТОТ ЖЕ, с которым сверяется вердикт ниже, и это не
|
||||||
|
# совпадение: пока флуд предлагает меньше, следующее утверждение зелено даже
|
||||||
|
# на системе вовсе без потолка, то есть измерения нет.
|
||||||
|
#
|
||||||
|
# Раньше условием было `len(codes) > connections` — «каждое соединение
|
||||||
|
# успело сходить хотя бы дважды за секунду». Это мерило скорости РАННЕРА, а
|
||||||
|
# не нагрузки: на занятом первый круг из ста запросов сам съедал всю секунду,
|
||||||
|
# и сторож краснел неотличимо от настоящей поломки потолка (замер 2026-08-07:
|
||||||
|
# свободная машина — 0 падений из 20, ~800 ответов/с; под load average ~185 —
|
||||||
|
# 10 падений из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с
|
||||||
|
# против порога 30/с, то есть мерить было на чем).
|
||||||
|
assert offered_per_s > max_attempts_per_s, (
|
||||||
|
f"нагрузку создать не удалось: флуд предложил {offered_per_s:.0f} запросов/с "
|
||||||
|
f"({len(codes)} за {elapsed:.2f}с) — не больше порога следующей проверки "
|
||||||
|
f"({max_attempts_per_s:.0f}/с), она прошла бы и без потолка. Это «раннер не "
|
||||||
|
f"потянул», НЕ «потолок сломан»"
|
||||||
)
|
)
|
||||||
assert attempts_per_s <= ceiling_per_s * 1.5, (
|
assert attempts_per_s <= max_attempts_per_s, (
|
||||||
f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с "
|
f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с "
|
||||||
f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает"
|
f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
334
tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py
Normal file
334
tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
"""Пол TTL по измеренному циклу переобхода (#2659).
|
||||||
|
|
||||||
|
Гейт здоровья (#2710) отвечает «источник собирается?». Этот пол отвечает на второй
|
||||||
|
вопрос issue — «а достаточно ли ttl_days, чтобы молчание означало снятие?». Пока свип
|
||||||
|
возвращается к строке реже, чем раз в ttl_days, TTL меряет нашу выборку, а не жизнь
|
||||||
|
объявления, и источник при этом ЗДОРОВ — гейт молчит.
|
||||||
|
|
||||||
|
Ключевой тест — test_effective_ttl_covers_every_proven_false_kill: он проигрывает
|
||||||
|
РЕАЛЬНЫЙ прод-случай (127 строк, снятых на возрасте 29.9..30.3 суток при TTL=30 и
|
||||||
|
доказанно вернувшихся живыми) и требует, чтобы эффективный TTL накрыл каждую. На
|
||||||
|
старом коде — без пола — эффективный TTL остаётся 30, и тест падает на всех срезах.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.tasks import deactivate_stale_avito as task_mod
|
||||||
|
|
||||||
|
# ── Прод-замер 2026-08-09 (read-only), из-за которого пол существует ───────────
|
||||||
|
# С деплоя гейта 06.08 TTL снял 1 028 строк, 127 уже снова активны (12.4%).
|
||||||
|
# В Екатеринбурге — единственном городе с настоящим покрытием — доля ложных 100%.
|
||||||
|
_FALSE_KILLS_BY_CITY: dict[str, tuple[int, int]] = { # срез -> (снято, снова активны)
|
||||||
|
"cian/Екатеринбург": (103, 103),
|
||||||
|
"yandex/Екатеринбург": (24, 24),
|
||||||
|
"cian/без города": (560, 0),
|
||||||
|
"yandex/без города": (341, 0),
|
||||||
|
}
|
||||||
|
# Возраст строки на момент снятия у всех 127 доказанно ложных снятий, суток.
|
||||||
|
_FALSE_KILL_AGE_MIN = 29.9
|
||||||
|
_FALSE_KILL_AGE_MAX = 30.3
|
||||||
|
|
||||||
|
# Пол, который отдаёт ТОТ ЖЕ запрос на проде (percentile_disc 0.99, окно 3 суток,
|
||||||
|
# срез = срез TTL-джобы). Ключ -> (listing_source, segments, ttl_days сейчас, пол).
|
||||||
|
_PROD_FLOORS: dict[str, tuple[str, list[str] | None, int, float]] = {
|
||||||
|
"cian/vtorichka": ("cian", ["vtorichka"], 30, 34.0),
|
||||||
|
"yandex/vtorichka": ("yandex", ["vtorichka"], 30, 74.3),
|
||||||
|
"avito/все сегменты": ("avito", None, 10, 69.7),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Разрывы переобхода по срезу TTL-джобы (listing_source_snapshots, 40 суток):
|
||||||
|
# источник -> (p90, p99, ttl_days сейчас). Домклик — контрольная группа: почти
|
||||||
|
# полное суточное покрытие, TTL лежит в 4.5 раза выше хвоста, и снятие у него
|
||||||
|
# действительно означает снятие.
|
||||||
|
_REVISIT_TAIL: dict[str, tuple[float, float, int]] = {
|
||||||
|
"domklik/vtorichka": (1.9, 3.1, 14),
|
||||||
|
"cian/vtorichka": (10.9, 26.6, 30),
|
||||||
|
"yandex/vtorichka": (5.7, 43.0, 30),
|
||||||
|
"avito/vtorichka": (29.1, 42.1, 10),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Фейковая сессия ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None:
|
||||||
|
self.rowcount = rowcount
|
||||||
|
self._scalar = scalar_value
|
||||||
|
|
||||||
|
def scalar(self) -> Any:
|
||||||
|
return self._scalar
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
floor_days: float | None,
|
||||||
|
confirmations: int = 10_000,
|
||||||
|
rowcount: int = 137,
|
||||||
|
) -> None:
|
||||||
|
self._floor = floor_days
|
||||||
|
self._confirmations = confirmations
|
||||||
|
self._rowcount = rowcount
|
||||||
|
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
||||||
|
self.committed = False
|
||||||
|
self.rolled_back = False
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||||
|
sql = str(stmt.text)
|
||||||
|
self.executed.append((sql, params))
|
||||||
|
if "percentile_disc" in sql:
|
||||||
|
return _FakeResult(scalar_value=self._floor)
|
||||||
|
if "SELECT count(*)" in sql:
|
||||||
|
return _FakeResult(scalar_value=self._confirmations)
|
||||||
|
return _FakeResult(rowcount=self._rowcount)
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
self.rolled_back = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def floor_query(self) -> tuple[str, dict[str, Any] | None]:
|
||||||
|
return next((e for e in self.executed if "percentile_disc" in e[0]), ("", None))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_query(self) -> tuple[str, dict[str, Any] | None]:
|
||||||
|
return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None))
|
||||||
|
|
||||||
|
|
||||||
|
def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]:
|
||||||
|
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
||||||
|
return task_mod.deactivate_stale_listings(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
1,
|
||||||
|
listing_source=kwargs.pop("listing_source", "cian"),
|
||||||
|
ttl_days=kwargs.pop("ttl_days", 30),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Исторический случай: 127 доказанных ложных снятий ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Ни одно из 127 доказанно ложных снятий не должно повториться.
|
||||||
|
|
||||||
|
Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть
|
||||||
|
строго выше этого возраста на КАЖДОМ прод-срезе — иначе следующий прогон
|
||||||
|
снимет ту же строку снова.
|
||||||
|
"""
|
||||||
|
for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items():
|
||||||
|
db = _FakeDB(floor_days=floor)
|
||||||
|
out = _run(
|
||||||
|
db,
|
||||||
|
monkeypatch,
|
||||||
|
listing_source=source,
|
||||||
|
ttl_days=ttl_days,
|
||||||
|
segments=segments,
|
||||||
|
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||||
|
)
|
||||||
|
effective = out["ttl_days_effective"]
|
||||||
|
assert effective > _FALSE_KILL_AGE_MAX, (
|
||||||
|
f"{slice_name}: эффективный TTL {effective} не накрывает возраст ложного "
|
||||||
|
f"снятия {_FALSE_KILL_AGE_MAX} — 127 строк снимутся снова"
|
||||||
|
)
|
||||||
|
_, update_params = db.update_query
|
||||||
|
assert update_params is not None
|
||||||
|
assert (
|
||||||
|
update_params["ttl_days"] == effective
|
||||||
|
), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
|
||||||
|
|
||||||
|
|
||||||
|
def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше."""
|
||||||
|
assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX
|
||||||
|
for source, _segments, ttl_days, _floor in _PROD_FLOORS.values():
|
||||||
|
if source in ("cian", "yandex"):
|
||||||
|
assert ttl_days == 30, f"{source}: прод-TTL разошёлся с замером"
|
||||||
|
|
||||||
|
|
||||||
|
def test_false_kill_rate_is_total_where_coverage_is_real() -> None:
|
||||||
|
"""В городе с настоящим покрытием ложны ВСЕ снятия — это и есть корень #2659."""
|
||||||
|
for slice_name in ("cian/Екатеринбург", "yandex/Екатеринбург"):
|
||||||
|
killed, returned = _FALSE_KILLS_BY_CITY[slice_name]
|
||||||
|
assert returned == killed, f"{slice_name}: замер разошёлся"
|
||||||
|
total_killed = sum(k for k, _ in _FALSE_KILLS_BY_CITY.values())
|
||||||
|
total_returned = sum(r for _, r in _FALSE_KILLS_BY_CITY.values())
|
||||||
|
assert total_killed == 1028
|
||||||
|
assert total_returned == 127
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_group_domklik_needs_no_floor() -> None:
|
||||||
|
"""Домклик — контроль: при почти полном суточном обходе TTL с запасом выше хвоста.
|
||||||
|
|
||||||
|
Если бы пол был нужен всем подряд, он был бы нужен и источнику со сплошным
|
||||||
|
покрытием. Ему не нужен — значит меряем именно покрытие, а не «TTL маловат».
|
||||||
|
|
||||||
|
Разделяет источники ЗАПАС над хвостом, а не сам факт превышения: у домклика
|
||||||
|
TTL/p99 = 4.5, у остальных трёх 0.24..1.13, то есть порог сидит вплотную к
|
||||||
|
хвосту или внутри него. Именно у самого «благополучного» из трёх (cian, 1.13)
|
||||||
|
и случились 103 доказанно ложных снятия: p99 запаса не даёт, снимает 1% живых.
|
||||||
|
"""
|
||||||
|
_p90, p99, ttl = _REVISIT_TAIL["domklik/vtorichka"]
|
||||||
|
assert ttl / p99 > 4, "домклик перестал быть контрольной группой — перемерить"
|
||||||
|
for name, (_p90, p99, ttl) in _REVISIT_TAIL.items():
|
||||||
|
if name.startswith("domklik"):
|
||||||
|
continue
|
||||||
|
assert ttl / p99 < 1.5, f"{name}: TTL отошёл от хвоста обхода, замер устарел"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Контракт пола ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_never_lowers_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Пол поднимает порог и только. Здоровый источник с коротким циклом не даёт
|
||||||
|
поводу снять больше, чем задано оператором."""
|
||||||
|
db = _FakeDB(floor_days=3.1)
|
||||||
|
out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99)
|
||||||
|
assert out["ttl_days_effective"] == 30
|
||||||
|
_, update_params = db.update_query
|
||||||
|
assert update_params is not None
|
||||||
|
assert update_params["ttl_days"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_is_rounded_up_not_down(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""34.0 суток разрыва -> TTL 34, а 34.2 -> 35: округление в сторону осторожности."""
|
||||||
|
db = _FakeDB(floor_days=34.2)
|
||||||
|
out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99)
|
||||||
|
assert out["revisit_floor_days"] == 35
|
||||||
|
assert out["ttl_days_effective"] == 35
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_disabled_keeps_old_behaviour(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""quantile=0 -> ни одного лишнего запроса, поведение как до правки."""
|
||||||
|
db = _FakeDB(floor_days=74.3)
|
||||||
|
out = _run(db, monkeypatch, ttl_days=30)
|
||||||
|
assert out == {"deactivated": 137}
|
||||||
|
assert len(db.executed) == 1
|
||||||
|
assert "percentile_disc" not in db.executed[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_snapshot_history_leaves_ttl_as_configured(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""NULL (истории снимков за окно нет) -> пол не выдумывается, TTL как задан."""
|
||||||
|
db = _FakeDB(floor_days=None)
|
||||||
|
out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99)
|
||||||
|
assert "revisit_floor_days" not in out
|
||||||
|
_, update_params = db.update_query
|
||||||
|
assert update_params is not None
|
||||||
|
assert update_params["ttl_days"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_runs_before_any_write(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Пол считается ДО UPDATE: снятое объявление возвращает только повторный сбор."""
|
||||||
|
db = _FakeDB(floor_days=74.3)
|
||||||
|
_run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99)
|
||||||
|
kinds = ["floor" if "percentile_disc" in sql else "update" for sql, _ in db.executed]
|
||||||
|
assert kinds.index("floor") < kinds.index("update")
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_measures_same_slice_as_update(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Срез пола совпадает со срезом UPDATE: тот же source и те же сегменты."""
|
||||||
|
db = _FakeDB(floor_days=74.3)
|
||||||
|
_run(
|
||||||
|
db,
|
||||||
|
monkeypatch,
|
||||||
|
listing_source="yandex",
|
||||||
|
segments=["vtorichka"],
|
||||||
|
revisit_floor_quantile=0.99,
|
||||||
|
)
|
||||||
|
floor_sql, floor_params = db.floor_query
|
||||||
|
assert "ANY(CAST(:segments AS text[]))" in floor_sql
|
||||||
|
assert floor_params is not None
|
||||||
|
assert floor_params["segments"] == ["vtorichka"]
|
||||||
|
assert floor_params["listing_source"] == "yandex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_uses_same_staleness_column_as_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""domklik считает свежесть по scraped_at (#2204) — пол обязан мерить ту же колонку,
|
||||||
|
иначе bulk-touch по last_seen_at показал бы цикл обхода там, где сбора нет."""
|
||||||
|
db = _FakeDB(floor_days=20.0)
|
||||||
|
_run(
|
||||||
|
db,
|
||||||
|
monkeypatch,
|
||||||
|
listing_source="domklik",
|
||||||
|
ttl_days=14,
|
||||||
|
staleness_column="scraped_at",
|
||||||
|
revisit_floor_quantile=0.99,
|
||||||
|
)
|
||||||
|
floor_sql, _ = db.floor_query
|
||||||
|
assert "l.scraped_at" in floor_sql
|
||||||
|
assert "l.last_seen_at" not in floor_sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_rejects_invalid_staleness_column(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Whitelist колонки работает и на пути пола — интерполяции чужого имени нет."""
|
||||||
|
db = _FakeDB(floor_days=20.0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_run(db, monkeypatch, staleness_column="is_active", revisit_floor_quantile=0.99)
|
||||||
|
assert db.executed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_gate_still_wins_over_floor(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Нездоровый источник блокируется гейтом ДО того, как считается пол:
|
||||||
|
лишний тяжёлый запрос по мёртвому источнику не нужен."""
|
||||||
|
db = _FakeDB(floor_days=74.3, confirmations=10)
|
||||||
|
out = _run(
|
||||||
|
db,
|
||||||
|
monkeypatch,
|
||||||
|
min_confirmations=500,
|
||||||
|
revisit_floor_quantile=0.99,
|
||||||
|
)
|
||||||
|
assert out["skipped_unhealthy"] == 1
|
||||||
|
assert db.floor_query[0] == ""
|
||||||
|
assert db.update_query[0] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_sql_is_psycopg_v3_safe() -> None:
|
||||||
|
sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text)
|
||||||
|
assert "CAST(:revisit_quantile AS double precision)" in sql
|
||||||
|
assert "CAST(:health_window_days AS integer)" in sql
|
||||||
|
assert not re.search(r":\w+::", sql)
|
||||||
|
assert "UPDATE" not in sql.upper()
|
||||||
|
assert "DELETE" not in sql.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_only_counts_rows_the_sweep_actually_refound() -> None:
|
||||||
|
"""Пол меряет выживших, а не «мы к ним не приходили»: свежесть обязана СДВИНУТЬСЯ
|
||||||
|
относительно предыдущего снимка, иначе разрыв нулевой и хвост схлопнется в ноль."""
|
||||||
|
sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text)
|
||||||
|
assert "l.last_seen_at > prev.last_seen_at" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_quantile_is_high_enough_for_the_prod_case() -> None:
|
||||||
|
"""Ниже 0.99 опускать нельзя без нового замера: именно на 0.99 прод-запрос даёт
|
||||||
|
34.0 для cian/vtorichka, что накрывает возраст ложных снятий 30.3."""
|
||||||
|
assert task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE >= 0.99
|
||||||
|
assert _PROD_FLOORS["cian/vtorichka"][3] > _FALSE_KILL_AGE_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_handler_wires_revisit_floor_from_schedule_params() -> None:
|
||||||
|
"""Читаем исходник файлом: product_handlers тянет scraper_kit, которого в
|
||||||
|
юнит-окружении может не быть, а проверяем мы проводку, а не импорт."""
|
||||||
|
handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py"
|
||||||
|
src = handlers.read_text("utf-8")
|
||||||
|
job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0]
|
||||||
|
flat = " ".join(job.split())
|
||||||
|
assert 'params.get( "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE )' in flat
|
||||||
|
assert "revisit_floor_quantile=revisit_floor_quantile" in job
|
||||||
|
|
@ -120,7 +120,7 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey
|
||||||
|
|
||||||
fake_fetcher = _FakeFetcher()
|
fake_fetcher = _FakeFetcher()
|
||||||
|
|
||||||
def _fake_build_browser_fetcher(config: object, source: str) -> _FakeFetcher:
|
def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher:
|
||||||
assert source == "domclick"
|
assert source == "domclick"
|
||||||
return fake_fetcher
|
return fake_fetcher
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,13 @@ def trade_in_app() -> FastAPI:
|
||||||
return application
|
return application
|
||||||
|
|
||||||
|
|
||||||
def _make_estimate_row(created_by: str | None) -> SimpleNamespace:
|
def _make_estimate_row(created_by: str | None, retain_until: object = None) -> SimpleNamespace:
|
||||||
"""A trade_in_estimates row with the full column set the endpoints read."""
|
"""A trade_in_estimates row with the full column set the endpoints read.
|
||||||
|
|
||||||
|
retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every
|
||||||
|
row that existed before that migration; explicit param lets retention-gate
|
||||||
|
tests (see test_estimate_retention_gate.py) construct a paid row.
|
||||||
|
"""
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
|
|
@ -69,6 +74,7 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace:
|
||||||
sources_used=["avito"],
|
sources_used=["avito"],
|
||||||
data_freshness_minutes=10,
|
data_freshness_minutes=10,
|
||||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
|
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
|
||||||
|
retain_until=retain_until,
|
||||||
address="ул. Тестовая, 1",
|
address="ул. Тестовая, 1",
|
||||||
lat=56.8,
|
lat=56.8,
|
||||||
lon=60.6,
|
lon=60.6,
|
||||||
|
|
@ -597,3 +603,116 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI)
|
||||||
headers={"X-Authenticated-User": "attacker"},
|
headers={"X-Authenticated-User": "attacker"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Payments retention: retention gate unification (retain_until, PR #2754) ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_readable_sql_uses_disjunction() -> None:
|
||||||
|
"""Single definition — OR retain_until, not a hand-copied expression."""
|
||||||
|
from app.api.v1.trade_in import ESTIMATE_READABLE_SQL
|
||||||
|
|
||||||
|
assert "expires_at > NOW()" in ESTIMATE_READABLE_SQL
|
||||||
|
assert "retain_until > NOW()" in ESTIMATE_READABLE_SQL
|
||||||
|
assert " OR " in ESTIMATE_READABLE_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_estimate_sql_built_from_shared_constant() -> None:
|
||||||
|
"""GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a
|
||||||
|
hand-copied literal — regression guard against the two gates drifting apart
|
||||||
|
again (that's exactly what happened before this PR: 404 here, 410 in /pdf)."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from app.api.v1.trade_in import get_estimate
|
||||||
|
|
||||||
|
src = inspect.getsource(get_estimate)
|
||||||
|
assert "ESTIMATE_READABLE_SQL" in src
|
||||||
|
assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant"
|
||||||
|
assert "retain_until" in src, "SELECT must also fetch retain_until"
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_pdf_select_includes_retain_until_column() -> None:
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from app.api.v1.trade_in import estimate_pdf
|
||||||
|
|
||||||
|
assert "retain_until" in inspect.getsource(estimate_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("expires_delta_hours", "retain_delta_days", "expected"),
|
||||||
|
[
|
||||||
|
(12, None, True), # not expired, unpaid — current B2B/B2C behaviour, unchanged
|
||||||
|
(-1, None, False), # expired, unpaid — current behaviour (404/410), unchanged
|
||||||
|
(-1, 365, True), # expired but PAID — new: readable
|
||||||
|
(12, 365, True), # not expired AND paid — readable
|
||||||
|
(-1, -1, False), # expired, and the (hypothetical) retain_until also in the past
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_estimate_readable_truth_table(
|
||||||
|
expires_delta_hours: int, retain_delta_days: int | None, expected: bool
|
||||||
|
) -> None:
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from app.api.v1.trade_in import estimate_readable
|
||||||
|
|
||||||
|
expires_at = datetime.now(tz=UTC) + timedelta(hours=expires_delta_hours)
|
||||||
|
retain_until = (
|
||||||
|
datetime.now(tz=UTC) + timedelta(days=retain_delta_days)
|
||||||
|
if retain_delta_days is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
assert estimate_readable(expires_at, retain_until) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_expired_but_paid_returns_200(trade_in_app: FastAPI) -> None:
|
||||||
|
"""expires_at in the past, retain_until in the future → PDF still downloads
|
||||||
|
(200). Exactly the scenario PR-D1 exists for: a paid report must outlive
|
||||||
|
the 24h expires_at link TTL."""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
row = _make_estimate_row(created_by="kopylov")
|
||||||
|
row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1)
|
||||||
|
row.retain_until = datetime.now(tz=UTC) + timedelta(days=300)
|
||||||
|
db_mock = _make_db_mock(row)
|
||||||
|
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "application/pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_expired_unpaid_returns_410_without_ttl_text(trade_in_app: FastAPI) -> None:
|
||||||
|
"""expires_at in the past, retain_until NULL (unpaid, unchanged behaviour) →
|
||||||
|
410, and the detail text no longer claims a specific '24h TTL' (would be a
|
||||||
|
lie now that retain_until exists for paid rows)."""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
row = _make_estimate_row(created_by="kopylov")
|
||||||
|
row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1)
|
||||||
|
row.retain_until = None
|
||||||
|
db_mock = _make_db_mock(row)
|
||||||
|
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 410
|
||||||
|
assert resp.json()["detail"] == "estimate expired"
|
||||||
|
assert "24h" not in resp.json()["detail"]
|
||||||
|
assert "TTL" not in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Response schema exposes retain_until (nullable) — schemas/trade_in.py."""
|
||||||
|
row = _make_estimate_row(created_by="kopylov") # retain_until defaults to None
|
||||||
|
db_mock = _make_db_mock(row)
|
||||||
|
client = _client_with(trade_in_app, db_mock, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["retain_until"] is None
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""Payments retention (PR #2754) — "12 месяцев" text sync guard.
|
||||||
|
|
||||||
|
WHY:
|
||||||
|
mera-public/content.ts declares itself as the ONE place product promises
|
||||||
|
live (docstring at the top of that file: "ни одного утверждения, которого
|
||||||
|
не делает код"). The public retention promise ("оплаченный отчёт хранится
|
||||||
|
N месяцев") has THREE places it could quietly drift: the backend setting
|
||||||
|
(`settings.trade_in_paid_retention_days`), the frontend constant
|
||||||
|
(`PAID_REPORT_RETENTION_MONTHS` in content.ts), and any page that renders
|
||||||
|
it (today: privacy/page.tsx). Deep-review finding 2026-08-06 MEDIUM on
|
||||||
|
PR #2754 caught exactly this: a comment claimed the number "reads from the
|
||||||
|
same setting" while the page actually hardcoded a `12 месяцев` literal --
|
||||||
|
a comment cannot fail CI, same lesson as
|
||||||
|
test_consent_text_frontend_sync.py's _CONSENT_TEXT_SNAPSHOT guard (which
|
||||||
|
this file mirrors).
|
||||||
|
|
||||||
|
WHAT:
|
||||||
|
1. privacy/page.tsx imports PAID_REPORT_RETENTION_MONTHS from content.ts
|
||||||
|
and does NOT hardcode a "N месяцев" literal of its own.
|
||||||
|
2. The frontend months constant and the backend days setting stay within
|
||||||
|
a sane calendar tolerance of each other (28-31 days per month) -- this
|
||||||
|
does NOT enforce byte-identity (days and months are different units by
|
||||||
|
design, see content.ts docstring), only that nobody silently changes
|
||||||
|
one without the other drifting out of "still honestly ~12 months".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
_FRONTEND_CONTENT = (
|
||||||
|
Path(__file__).resolve().parents[2] / "frontend" / "src" / "app" / "mera-public" / "content.ts"
|
||||||
|
)
|
||||||
|
_FRONTEND_PRIVACY_PAGE = (
|
||||||
|
Path(__file__).resolve().parents[2]
|
||||||
|
/ "frontend"
|
||||||
|
/ "src"
|
||||||
|
/ "app"
|
||||||
|
/ "mera-public"
|
||||||
|
/ "privacy"
|
||||||
|
/ "page.tsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
_MONTHS_CONST_RE = re.compile(r"PAID_REPORT_RETENTION_MONTHS\s*=\s*(\d+)\s*;")
|
||||||
|
_LITERAL_MONTHS_RE = re.compile(r'"?\d+ месяцев"?')
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_months_constant(content_ts_source: str) -> int:
|
||||||
|
match = _MONTHS_CONST_RE.search(content_ts_source)
|
||||||
|
assert match is not None, (
|
||||||
|
"PAID_REPORT_RETENTION_MONTHS not found in content.ts -- "
|
||||||
|
"constant renamed/removed without updating this test"
|
||||||
|
)
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_files_exist() -> None:
|
||||||
|
assert _FRONTEND_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_CONTENT}"
|
||||||
|
assert _FRONTEND_PRIVACY_PAGE.is_file(), f"missing frontend file: {_FRONTEND_PRIVACY_PAGE}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_page_imports_retention_constant_not_hardcoded() -> None:
|
||||||
|
"""The whole point: FAILS if privacy/page.tsx stops importing the shared
|
||||||
|
constant and goes back to a hardcoded '12 месяцев' literal (exactly the
|
||||||
|
drift the deep-review finding caught -- comment said 'reads from content.ts',
|
||||||
|
code said otherwise)."""
|
||||||
|
src = _FRONTEND_PRIVACY_PAGE.read_text(encoding="utf-8")
|
||||||
|
assert "PAID_REPORT_RETENTION_MONTHS" in src, (
|
||||||
|
"privacy/page.tsx no longer references PAID_REPORT_RETENTION_MONTHS -- "
|
||||||
|
"the retention promise must be rendered from the shared content.ts "
|
||||||
|
"constant, not written out again by hand"
|
||||||
|
)
|
||||||
|
assert re.search(r'from\s+"\.\./content"', src), (
|
||||||
|
"privacy/page.tsx must import from '../content' (content.ts), where "
|
||||||
|
"PAID_REPORT_RETENTION_MONTHS is declared"
|
||||||
|
)
|
||||||
|
literal_hits = _LITERAL_MONTHS_RE.findall(src)
|
||||||
|
assert not literal_hits, (
|
||||||
|
"privacy/page.tsx contains a hardcoded 'N месяцев' literal -- render the "
|
||||||
|
"PAID_REPORT_RETENTION_MONTHS constant instead: "
|
||||||
|
f"{literal_hits!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_days_setting_matches_frontend_months_within_calendar_tolerance() -> None:
|
||||||
|
"""Not byte-identity (days vs months are different units, deliberately --
|
||||||
|
see content.ts docstring on PAID_REPORT_RETENTION_MONTHS): just a sanity
|
||||||
|
bound that `trade_in_paid_retention_days` still honestly rounds to the
|
||||||
|
number of months the public page promises (28-31 days/month, generous)."""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
days = settings.trade_in_paid_retention_days
|
||||||
|
months = _extract_months_constant(_FRONTEND_CONTENT.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
assert 28 * months <= days <= 31 * months, (
|
||||||
|
f"settings.trade_in_paid_retention_days={days} no longer honestly rounds to "
|
||||||
|
f"content.ts PAID_REPORT_RETENTION_MONTHS={months} -- update both together "
|
||||||
|
"(and the offer text, when it exists) so the public promise stays true"
|
||||||
|
)
|
||||||
|
|
@ -445,3 +445,36 @@ def test_brand_not_taken_from_query_param_docstring() -> None:
|
||||||
assert (
|
assert (
|
||||||
"brand" not in param_names
|
"brand" not in param_names
|
||||||
), "estimate_pdf should NOT have a 'brand' query param after #7 fix"
|
), "estimate_pdf should NOT have a 'brand' query param after #7 fix"
|
||||||
|
|
||||||
|
|
||||||
|
# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_no_retain_until_row_when_unpaid() -> None:
|
||||||
|
"""retain_until IS NULL (default, all current traffic) → no 'Ссылка доступна
|
||||||
|
до' row at all — B2B regression guard, cover renders bit-for-bit as before."""
|
||||||
|
est = _estimate()
|
||||||
|
assert est.retain_until is None
|
||||||
|
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
|
||||||
|
assert "Ссылка доступна до" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_renders_retain_until_row_when_paid() -> None:
|
||||||
|
"""retain_until IS NOT NULL → 'Ссылка доступна до <date>' row present, with
|
||||||
|
its OWN date (not conflated with 'Срок действия данных' / expires_at)."""
|
||||||
|
retain = datetime(2027, 8, 6, tzinfo=UTC)
|
||||||
|
est = _estimate(retain_until=retain)
|
||||||
|
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
|
||||||
|
assert "Ссылка доступна до" in html
|
||||||
|
assert "06.08.2027" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_expires_date_unaffected_by_retain_until() -> None:
|
||||||
|
"""«ДЕЙСТВИТЕЛЕН ДО» (running footer, _expires_date) stays wired to
|
||||||
|
expires_at regardless of retain_until — it is data-actuality, not the
|
||||||
|
paid-access retention window, and must not move when a report is paid."""
|
||||||
|
expires = datetime.now(UTC) + timedelta(hours=24)
|
||||||
|
est_unpaid = _estimate(expires_at=expires)
|
||||||
|
est_paid = _estimate(expires_at=expires, retain_until=expires + timedelta(days=365))
|
||||||
|
assert mod._expires_date(est_unpaid) == expires.date()
|
||||||
|
assert mod._expires_date(est_paid) == expires.date()
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,22 @@ Covers app/tasks/purge_expired_trade_in_data.py:
|
||||||
- both tables (trade_in_estimates, trade_in_leads) get drained
|
- both tables (trade_in_estimates, trade_in_leads) get drained
|
||||||
- failure path: rollback + mark_failed with partial counters, exception re-raised
|
- failure path: rollback + mark_failed with partial counters, exception re-raised
|
||||||
- SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap
|
- SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap
|
||||||
|
- Payments retention (PR #2754): retain_until IS NULL + NOT EXISTS(payments)
|
||||||
|
safeguards on the estimates DELETE, plus a pre-flight that refuses to run
|
||||||
|
at all if it finds an ANOMALOUS paid purge-candidate (retain_until unset
|
||||||
|
despite a payments row) -- a healthy paid row (retain_until set) must NOT
|
||||||
|
trip it, see test_preflight_ignores_healthy_paid_row below.
|
||||||
|
|
||||||
Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched
|
Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched
|
||||||
runs_mod.mark_done/mark_failed).
|
runs_mod.mark_done/mark_failed).
|
||||||
|
|
||||||
|
Payments retention note on _FakeDB: purge_expired_trade_in_data now issues ONE
|
||||||
|
extra db.execute() call BEFORE any DELETE batch — the pre-flight paid-
|
||||||
|
candidates count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that
|
||||||
|
statement by identity and answers it from `preflight_count` (default 0 ==
|
||||||
|
"no anomalous candidates, proceed exactly as before this PR"). Every
|
||||||
|
pre-existing test's `db.executed` index shifted by +1 to account for this;
|
||||||
|
`db.commits` is unaffected (the pre-flight is a read, never committed).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -34,18 +47,30 @@ class _FakeResult:
|
||||||
def __init__(self, rowcount: int) -> None:
|
def __init__(self, rowcount: int) -> None:
|
||||||
self.rowcount = rowcount
|
self.rowcount = rowcount
|
||||||
|
|
||||||
|
def scalar_one(self) -> int:
|
||||||
|
"""Supports the PR-D1 pre-flight `SELECT count(*) ... .scalar_one()` call."""
|
||||||
|
return self.rowcount
|
||||||
|
|
||||||
|
|
||||||
class _FakeDB:
|
class _FakeDB:
|
||||||
"""Pops rowcounts in call order -- caller supplies the exact sequence expected."""
|
"""Pops rowcounts in call order -- caller supplies the exact sequence expected.
|
||||||
|
|
||||||
def __init__(self, rowcounts: list[int]) -> None:
|
PR-D1: the pre-flight paid-candidates count is answered separately, from
|
||||||
|
`preflight_count` (default 0), keyed by statement IDENTITY -- it never
|
||||||
|
consumes an entry off `rowcounts` (that list is DELETE-batch rowcounts only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, rowcounts: list[int], *, preflight_count: int = 0) -> None:
|
||||||
self._rowcounts = list(rowcounts)
|
self._rowcounts = list(rowcounts)
|
||||||
|
self.preflight_count = preflight_count
|
||||||
self.executed: list[tuple[Any, Any]] = []
|
self.executed: list[tuple[Any, Any]] = []
|
||||||
self.commits = 0
|
self.commits = 0
|
||||||
self.rolled_back = False
|
self.rolled_back = False
|
||||||
|
|
||||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||||
self.executed.append((stmt, params))
|
self.executed.append((stmt, params))
|
||||||
|
if stmt is task_mod._PREFLIGHT_PAID_CANDIDATES_SQL:
|
||||||
|
return _FakeResult(self.preflight_count)
|
||||||
return _FakeResult(self._rowcounts.pop(0))
|
return _FakeResult(self._rowcounts.pop(0))
|
||||||
|
|
||||||
def commit(self) -> None:
|
def commit(self) -> None:
|
||||||
|
|
@ -83,7 +108,9 @@ def test_stops_when_batch_below_size(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
db = _FakeDB([3, 0])
|
db = _FakeDB([3, 0])
|
||||||
out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type]
|
out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type]
|
||||||
assert out == {"estimates_deleted": 3, "leads_deleted": 0}
|
assert out == {"estimates_deleted": 3, "leads_deleted": 0}
|
||||||
assert len(db.executed) == 2
|
# +1 vs pre-PR-D1: db.executed now also holds the pre-flight paid-candidates
|
||||||
|
# count (call #1), issued before either DELETE batch.
|
||||||
|
assert len(db.executed) == 3
|
||||||
assert db.commits == 2
|
assert db.commits == 2
|
||||||
assert marked["counters"] == out
|
assert marked["counters"] == out
|
||||||
|
|
||||||
|
|
@ -94,7 +121,7 @@ def test_loops_until_below_batch_size(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
db = _FakeDB([5, 5, 2, 5, 1])
|
db = _FakeDB([5, 5, 2, 5, 1])
|
||||||
out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type]
|
out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type]
|
||||||
assert out == {"estimates_deleted": 12, "leads_deleted": 6}
|
assert out == {"estimates_deleted": 12, "leads_deleted": 6}
|
||||||
assert len(db.executed) == 5
|
assert len(db.executed) == 6 # +1: pre-flight call before the 5 DELETE batches
|
||||||
assert db.commits == 5, "each batch must commit independently, not one final commit"
|
assert db.commits == 5, "each batch must commit independently, not one final commit"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,14 +133,16 @@ def test_respects_max_batches_cap(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
db = _FakeDB([5, 5, 5, 5, 5, 5]) # exactly max_batches=3 per table, no more
|
db = _FakeDB([5, 5, 5, 5, 5, 5]) # exactly max_batches=3 per table, no more
|
||||||
out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type]
|
out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type]
|
||||||
assert out == {"estimates_deleted": 15, "leads_deleted": 15}
|
assert out == {"estimates_deleted": 15, "leads_deleted": 15}
|
||||||
assert len(db.executed) == 6 # 3 (estimates) + 3 (leads), NOT unbounded
|
assert len(db.executed) == 7 # pre-flight + 3 (estimates) + 3 (leads), NOT unbounded
|
||||||
|
|
||||||
|
|
||||||
def test_default_batch_size_and_max_batches_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_default_batch_size_and_max_batches_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
_patch_runs(monkeypatch)
|
_patch_runs(monkeypatch)
|
||||||
db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately
|
db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately
|
||||||
task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type]
|
task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type]
|
||||||
_stmt, params = db.executed[0]
|
# db.executed[0] is now the pre-flight call (no batch_size param) -- the
|
||||||
|
# first DELETE-batch call (with batch_size) shifted to index 1.
|
||||||
|
_stmt, params = db.executed[1]
|
||||||
assert params is not None
|
assert params is not None
|
||||||
assert params["batch_size"] == task_mod.settings.trade_in_purge_batch_size
|
assert params["batch_size"] == task_mod.settings.trade_in_purge_batch_size
|
||||||
|
|
||||||
|
|
@ -125,8 +154,9 @@ def test_drains_both_tables_in_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
_patch_runs(monkeypatch)
|
_patch_runs(monkeypatch)
|
||||||
db = _FakeDB([0, 0])
|
db = _FakeDB([0, 0])
|
||||||
task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type]
|
task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type]
|
||||||
first_sql = str(getattr(db.executed[0][0], "text", db.executed[0][0]))
|
# index 0 is now the pre-flight call; DELETE batches shifted to 1/2.
|
||||||
second_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0]))
|
first_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0]))
|
||||||
|
second_sql = str(getattr(db.executed[2][0], "text", db.executed[2][0]))
|
||||||
assert "trade_in_estimates" in first_sql
|
assert "trade_in_estimates" in first_sql
|
||||||
assert "trade_in_leads" in second_sql
|
assert "trade_in_leads" in second_sql
|
||||||
|
|
||||||
|
|
@ -141,6 +171,86 @@ def test_estimates_sql_is_delete_not_update() -> None:
|
||||||
assert not re.search(r":\w+::", sql)
|
assert not re.search(r":\w+::", sql)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Payments retention (PR #2754): two independent purge safeguards ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimates_sql_excludes_retain_until_not_null() -> None:
|
||||||
|
"""Phase 1: exactly `retain_until IS NULL`, never `< NOW()` -- a paid row
|
||||||
|
(retain_until IS NOT NULL) must never match the DELETE predicate, full stop,
|
||||||
|
regardless of how far in the past that date eventually sits."""
|
||||||
|
sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text
|
||||||
|
assert "retain_until IS NULL" in sql
|
||||||
|
assert "retain_until <" not in sql, "phase 1 must not weaken to retain_until < NOW()"
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimates_sql_has_not_exists_payments_safeguard() -> None:
|
||||||
|
"""Independent safeguard: a row with ANY payments row survives even if
|
||||||
|
retain_until failed to be set (fulfillment bug/race/manual INSERT)."""
|
||||||
|
sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text
|
||||||
|
assert "NOT EXISTS" in sql
|
||||||
|
assert "FROM payments p" in sql
|
||||||
|
assert "p.estimate_id = trade_in_estimates.id" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_sql_requires_retain_until_is_null() -> None:
|
||||||
|
"""Deep-review finding 2026-08-06 MEDIUM (PR #2754): the pre-flight predicate
|
||||||
|
MUST carry `retain_until IS NULL` -- without it, a perfectly healthy paid row
|
||||||
|
(retain_until set, has a payments row -- the ORDINARY steady state one day
|
||||||
|
after every sale) trips the alarm exactly as hard as the real anomaly
|
||||||
|
(retain_until unset despite a payments row existing), permanently wedging
|
||||||
|
the job on the very first successful sale (and, since leads purge runs from
|
||||||
|
the same function AFTER this check, silently stopping 180-day 152-ФЗ lead
|
||||||
|
retention too). See test_real_preflight_ignores_healthy_paid_row below for
|
||||||
|
the behavioural proof against a real DB."""
|
||||||
|
sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text
|
||||||
|
assert "expires_at < NOW()" in sql
|
||||||
|
assert "created_by IS NULL" in sql
|
||||||
|
assert "retain_until IS NULL" in sql
|
||||||
|
assert "EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)" in sql
|
||||||
|
assert not re.search(r":\w+::", sql)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_aborts_before_any_delete_batch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Non-zero pre-flight count -> RuntimeError, mark_failed, ZERO DELETE batches
|
||||||
|
ever issued (only the pre-flight SELECT itself is in db.executed)."""
|
||||||
|
marked = _patch_runs(monkeypatch)
|
||||||
|
db = _FakeDB([], preflight_count=2) # rowcounts empty on purpose: must never be popped
|
||||||
|
with pytest.raises(RuntimeError, match="pre-flight abort"):
|
||||||
|
task_mod.purge_expired_trade_in_data(db, run_id=42, batch_size=10, max_batches=20) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert len(db.executed) == 1, "only the pre-flight SELECT -- no DELETE batch was issued"
|
||||||
|
assert db.commits == 0
|
||||||
|
assert marked["kind"] == "failed"
|
||||||
|
assert marked["run_id"] == 42
|
||||||
|
assert marked["counters"] == {"estimates_deleted": 0, "leads_deleted": 0}
|
||||||
|
assert "2" in marked["err"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_zero_candidates_proceeds_as_before(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""preflight_count=0 (default) -- the exact pre-PR-D1 behaviour for every
|
||||||
|
row that exists today (all retain_until IS NULL) -- run proceeds normally."""
|
||||||
|
marked = _patch_runs(monkeypatch)
|
||||||
|
db = _FakeDB([0, 0]) # preflight_count defaults to 0
|
||||||
|
out = task_mod.purge_expired_trade_in_data(db, run_id=43, batch_size=10, max_batches=20) # type: ignore[arg-type]
|
||||||
|
assert out == {"estimates_deleted": 0, "leads_deleted": 0}
|
||||||
|
assert marked["kind"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leads_sql_unchanged_by_pr_d1() -> None:
|
||||||
|
"""Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by payments
|
||||||
|
retention (PR #2754) — leads have their own retention deadline (migration
|
||||||
|
231, no created_by/B2B split, no payments concept) and are explicitly out
|
||||||
|
of scope for the payments-retention safeguards."""
|
||||||
|
expected = (
|
||||||
|
"\n DELETE FROM trade_in_leads\n WHERE id IN (\n"
|
||||||
|
" SELECT id FROM trade_in_leads\n"
|
||||||
|
" WHERE expires_at < NOW()\n"
|
||||||
|
" ORDER BY expires_at\n"
|
||||||
|
" LIMIT CAST(:batch_size AS int)\n )\n "
|
||||||
|
)
|
||||||
|
assert task_mod._DELETE_EXPIRED_LEADS_SQL.text == expected
|
||||||
|
|
||||||
|
|
||||||
def test_leads_sql_is_delete_not_update() -> None:
|
def test_leads_sql_is_delete_not_update() -> None:
|
||||||
sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text
|
sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text
|
||||||
assert "DELETE FROM trade_in_leads" in sql
|
assert "DELETE FROM trade_in_leads" in sql
|
||||||
|
|
@ -199,13 +309,14 @@ def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch)
|
||||||
|
|
||||||
class _BoomDB(_FakeDB):
|
class _BoomDB(_FakeDB):
|
||||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||||
# First batch (estimates) succeeds and commits; second call (still
|
# Call #1 (pre-flight, preflight_count=0) and call #2 (first estimates
|
||||||
# draining estimates, or first leads call) explodes.
|
# batch) succeed and commit; call #3 (still draining estimates, or
|
||||||
if len(self.executed) >= 1:
|
# first leads call) explodes. +1 vs pre-PR-D1 to admit the pre-flight.
|
||||||
|
if len(self.executed) >= 2:
|
||||||
raise RuntimeError("db exploded")
|
raise RuntimeError("db exploded")
|
||||||
return super().execute(stmt, params)
|
return super().execute(stmt, params)
|
||||||
|
|
||||||
db = _BoomDB([5]) # only ONE successful batch before the boom
|
db = _BoomDB([5]) # only ONE successful DELETE batch before the boom
|
||||||
with pytest.raises(RuntimeError, match="db exploded"):
|
with pytest.raises(RuntimeError, match="db exploded"):
|
||||||
task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type]
|
task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
@ -339,3 +450,160 @@ def test_real_purge_deletes_only_anonymous_expired_estimates() -> None:
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||||
|
def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None:
|
||||||
|
"""Deep-review finding 2026-08-06 MEDIUM on PR #2754, reproduced exactly
|
||||||
|
against a real DB: a HEALTHY paid row (retain_until set, a payments row
|
||||||
|
exists) is the ordinary steady state one day after every sale and must NOT
|
||||||
|
raise the pre-flight count; a row where fulfillment failed to set
|
||||||
|
retain_until despite a payments row existing is the real ANOMALY and must.
|
||||||
|
Baseline-delta assertions (not absolute counts) so this is safe to run
|
||||||
|
against a dev DB that may already contain unrelated rows."""
|
||||||
|
from sqlalchemy import text as _t
|
||||||
|
|
||||||
|
db = _live_session()
|
||||||
|
assert db is not None
|
||||||
|
healthy_id = uuid4()
|
||||||
|
anomaly_id = uuid4()
|
||||||
|
healthy_order = f"pytest-healthy-{uuid4().hex[:12]}"
|
||||||
|
anomaly_order = f"pytest-anomaly-{uuid4().hex[:12]}"
|
||||||
|
try:
|
||||||
|
baseline = task_mod._preflight_paid_candidates(db)
|
||||||
|
|
||||||
|
# Healthy: retain_until set (paid, safeguard 1 already protects it) +
|
||||||
|
# a payments row -- exactly what every successful sale looks like a day
|
||||||
|
# later. Must NOT move the pre-flight count.
|
||||||
|
db.execute(
|
||||||
|
_t(
|
||||||
|
"INSERT INTO trade_in_estimates "
|
||||||
|
"(id, address, area_m2, rooms, floor, total_floors, "
|
||||||
|
" median_price, range_low, range_high, median_price_per_m2, confidence, "
|
||||||
|
" expires_at, created_by, retain_until) VALUES "
|
||||||
|
"(CAST(:id AS uuid), 'purge-test здоровая оплаченная', 40, 1, 2, 5, "
|
||||||
|
" 5000000, 4500000, 5500000, 125000, 'low', "
|
||||||
|
" NOW() - interval '1 hour', NULL, NOW() + interval '363 days')"
|
||||||
|
),
|
||||||
|
{"id": str(healthy_id)},
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
_t(
|
||||||
|
"INSERT INTO payments "
|
||||||
|
"(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) "
|
||||||
|
"VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', "
|
||||||
|
" CAST(:id AS uuid))"
|
||||||
|
),
|
||||||
|
{"order_id": healthy_order, "id": str(healthy_id)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
assert (
|
||||||
|
task_mod._preflight_paid_candidates(db) == baseline
|
||||||
|
), "healthy paid row (retain_until set) must NOT raise the pre-flight count"
|
||||||
|
|
||||||
|
# Anomaly: retain_until NULL despite a payments row existing -- exactly
|
||||||
|
# the case the two DELETE safeguards exist for. Must raise by exactly one.
|
||||||
|
db.execute(
|
||||||
|
_t(
|
||||||
|
"INSERT INTO trade_in_estimates "
|
||||||
|
"(id, address, area_m2, rooms, floor, total_floors, "
|
||||||
|
" median_price, range_low, range_high, median_price_per_m2, confidence, "
|
||||||
|
" expires_at, created_by, retain_until) VALUES "
|
||||||
|
"(CAST(:id AS uuid), 'purge-test настоящая аномалия', 40, 1, 2, 5, "
|
||||||
|
" 5000000, 4500000, 5500000, 125000, 'low', "
|
||||||
|
" NOW() - interval '1 hour', NULL, NULL)"
|
||||||
|
),
|
||||||
|
{"id": str(anomaly_id)},
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
_t(
|
||||||
|
"INSERT INTO payments "
|
||||||
|
"(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) "
|
||||||
|
"VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', "
|
||||||
|
" CAST(:id AS uuid))"
|
||||||
|
),
|
||||||
|
{"order_id": anomaly_order, "id": str(anomaly_id)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
assert task_mod._preflight_paid_candidates(db) == baseline + 1, (
|
||||||
|
"anomaly row (retain_until unset, payments row exists) must raise "
|
||||||
|
"the pre-flight count by exactly one"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.execute(
|
||||||
|
_t("DELETE FROM payments WHERE order_id = ANY(CAST(:orders AS text[]))"),
|
||||||
|
{"orders": [healthy_order, anomaly_order]},
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
_t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"),
|
||||||
|
{"ids": [str(healthy_id), str(anomaly_id)]},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||||
|
def test_real_purge_not_wedged_by_healthy_paid_row() -> None:
|
||||||
|
"""Deep-review finding 2026-08-06 MEDIUM on PR #2754: before the fix, a
|
||||||
|
healthy paid row anywhere in the table (retain_until set, has a payments
|
||||||
|
row) permanently wedged the job -- the very first successful sale would
|
||||||
|
have made every subsequent scheduled run abort in mark_failed with zero
|
||||||
|
deletions FOREVER, silently taking 180-day leads purge (152-ФЗ) down with
|
||||||
|
it (leads purge runs from the same function AFTER the pre-flight check).
|
||||||
|
This proves a real end-to-end run completes normally (mark_done) in the
|
||||||
|
presence of such a row."""
|
||||||
|
from sqlalchemy import text as _t
|
||||||
|
|
||||||
|
db = _live_session()
|
||||||
|
assert db is not None
|
||||||
|
healthy_id = uuid4()
|
||||||
|
healthy_order = f"pytest-wedge-{uuid4().hex[:12]}"
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
_t(
|
||||||
|
"INSERT INTO trade_in_estimates "
|
||||||
|
"(id, address, area_m2, rooms, floor, total_floors, "
|
||||||
|
" median_price, range_low, range_high, median_price_per_m2, confidence, "
|
||||||
|
" expires_at, created_by, retain_until) VALUES "
|
||||||
|
"(CAST(:id AS uuid), 'purge-test не блокирует джобу', 40, 1, 2, 5, "
|
||||||
|
" 5000000, 4500000, 5500000, 125000, 'low', "
|
||||||
|
" NOW() - interval '1 hour', NULL, NOW() + interval '363 days')"
|
||||||
|
),
|
||||||
|
{"id": str(healthy_id)},
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
_t(
|
||||||
|
"INSERT INTO payments "
|
||||||
|
"(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) "
|
||||||
|
"VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', "
|
||||||
|
" CAST(:id AS uuid))"
|
||||||
|
),
|
||||||
|
{"order_id": healthy_order, "id": str(healthy_id)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Must complete normally -- no RuntimeError, no mark_failed short-circuit
|
||||||
|
# (would raise before reaching this line if the bug were still present).
|
||||||
|
result = task_mod.purge_expired_trade_in_data(
|
||||||
|
db, run_id=999999998, batch_size=100, max_batches=1
|
||||||
|
)
|
||||||
|
assert set(result) == {"estimates_deleted", "leads_deleted"}, (
|
||||||
|
"leads purge must also have run -- it is NOT reachable when the "
|
||||||
|
"pre-flight wrongly aborts first"
|
||||||
|
)
|
||||||
|
|
||||||
|
still_there = db.execute(
|
||||||
|
_t("SELECT id FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||||
|
{"id": str(healthy_id)},
|
||||||
|
).fetchone()
|
||||||
|
assert still_there is not None, "healthy paid row must survive the run untouched"
|
||||||
|
finally:
|
||||||
|
db.execute(
|
||||||
|
_t("DELETE FROM payments WHERE order_id = :order_id"), {"order_id": healthy_order}
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
_t("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||||
|
{"id": str(healthy_id)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -236,9 +236,14 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi
|
||||||
|
|
||||||
captured: dict[str, Any] = {}
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
async def _fake_fetch_newbuilding(zhk_url: str, *, config: Any = None) -> None:
|
async def _fake_fetch_newbuilding(
|
||||||
|
zhk_url: str, *, config: Any = None, proxy_provider: Any = None
|
||||||
|
) -> None:
|
||||||
captured["zhk_url"] = zhk_url
|
captured["zhk_url"] = zhk_url
|
||||||
captured["endpoint"] = config.browser_http_endpoint if config is not None else None
|
captured["endpoint"] = config.browser_http_endpoint if config is not None else None
|
||||||
|
# #2767: этот путь тоже обязан идти через пул прокси — до правки он, как и
|
||||||
|
# обогащение, выходил через единственный env-узел сайдкара.
|
||||||
|
captured["proxy_provider"] = proxy_provider
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.return_value.mappings.return_value.all.return_value = [
|
db.execute.return_value.mappings.return_value.all.return_value = [
|
||||||
|
|
@ -258,6 +263,7 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi
|
||||||
|
|
||||||
assert captured["zhk_url"] == "https://zhk-test-ekb-i.cian.ru/"
|
assert captured["zhk_url"] == "https://zhk-test-ekb-i.cian.ru/"
|
||||||
assert captured["endpoint"] == settings.browser_http_endpoint
|
assert captured["endpoint"] == settings.browser_http_endpoint
|
||||||
|
assert captured["proxy_provider"] is not None
|
||||||
|
|
||||||
|
|
||||||
async def test_cian_history_backfill_valuations_estimate_gets_config() -> None:
|
async def test_cian_history_backfill_valuations_estimate_gets_config() -> None:
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,16 @@ def _spy_browser_fetcher(html: str) -> MagicMock:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
|
async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
|
||||||
"""config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...)."""
|
"""config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...).
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT)
|
С #2767 путь идёт через `build_browser_fetcher` (подключение к пулу прокси), поэтому
|
||||||
|
подменяется BrowserFetcher В ФАБРИКЕ — сама фабрика при этом настоящая, и тест
|
||||||
|
проверяет всю цепочку config → фабрика → фетчер, а не контракт мока.
|
||||||
|
"""
|
||||||
|
spy = _spy_browser_fetcher("<html></html>")
|
||||||
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
|
config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
|
||||||
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", config=config)
|
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", config=config)
|
||||||
|
|
||||||
spy.assert_called_once()
|
spy.assert_called_once()
|
||||||
|
|
|
||||||
34
tradein-mvp/frontend/package-lock.json
generated
34
tradein-mvp/frontend/package-lock.json
generated
|
|
@ -6343,6 +6343,19 @@
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
|
@ -6803,13 +6816,13 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
|
@ -7828,19 +7841,6 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
|
||||||
"version": "4.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinypool": {
|
"node_modules/tinypool": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,25 @@ export const LEGAL_ENTITY: {
|
||||||
/** Внутренний маршрут страницы про обработку персональных данных. */
|
/** Внутренний маршрут страницы про обработку персональных данных. */
|
||||||
export const PRIVACY_PATH = "/mera-public/privacy";
|
export const PRIVACY_PATH = "/mera-public/privacy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта
|
||||||
|
* после оплаты (`trade_in_estimates.retain_until`, migration 240) — НЕ срок
|
||||||
|
* действия самого расчёта (тот отдельный, `expires_at`, часы).
|
||||||
|
*
|
||||||
|
* ЕДИНСТВЕННОЕ место, где это число хардкодится на фронте — любой другой
|
||||||
|
* текст (оферта, экран после оплаты) обязан импортировать эту константу, а
|
||||||
|
* не писать «12 месяцев» заново (см. `test_paid_retention_text_consistency.py`
|
||||||
|
* — проверяет, что privacy-страница действительно использует эту константу,
|
||||||
|
* а не литерал).
|
||||||
|
*
|
||||||
|
* Источник истины на бэкенде — `settings.trade_in_paid_retention_days = 365`
|
||||||
|
* (`app/core/config.py`). 365 дней округляется до «12 месяцев» для
|
||||||
|
* человекочитаемого текста (12×30=360..12×31=372 — 365 попадает в диапазон);
|
||||||
|
* если бэкендовое число когда-нибудь изменится так, что «12» перестанет быть
|
||||||
|
* честным округлением — обновить оба места руками, тест это тоже проверяет.
|
||||||
|
*/
|
||||||
|
export const PAID_REPORT_RETENTION_MONTHS = 12;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// География
|
// География
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import Link from "next/link";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LEGAL_ENTITY,
|
LEGAL_ENTITY,
|
||||||
|
PAID_REPORT_RETENTION_MONTHS,
|
||||||
PUBLIC_ESTIMATE_ENABLED,
|
PUBLIC_ESTIMATE_ENABLED,
|
||||||
SUPPORT_TELEGRAM_LABEL,
|
SUPPORT_TELEGRAM_LABEL,
|
||||||
SUPPORT_TELEGRAM_URL,
|
SUPPORT_TELEGRAM_URL,
|
||||||
|
|
@ -32,11 +33,26 @@ import { safeUrl } from "@/lib/safeUrl";
|
||||||
* квалификация — не наше дело: заявка привязывается к конкретному расчёту
|
* квалификация — не наше дело: заявка привязывается к конкретному расчёту
|
||||||
* (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее
|
* (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее
|
||||||
* сохранённым адресом. Финальную формулировку даёт юрист.
|
* сохранённым адресом. Финальную формулировку даёт юрист.
|
||||||
* - «мы удалим ваш телефон и заявку». Механизма удаления в бэкенде НЕТ:
|
* - «мы обещаем удалить всё вообще» без оговорок. После #2547 механизм
|
||||||
* ни `DELETE FROM trade_in_leads/trade_in_estimates` в коде, ни
|
* удаления в бэкенде ЕСТЬ: `app/services/data_erasure.py` (ручное
|
||||||
* retention/erasure-джоба среди `app/tasks/**` (проверено grep'ом);
|
* удаление по обращению — по estimate_id/телефону/Telegram chat id) и
|
||||||
* `expires_at` применяется только на чтении. Обещать удаление до появления
|
* `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`: пока
|
* Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока
|
||||||
* расчёт выключен, адрес действительно не покидает браузер; после включения это
|
* расчёт выключен, адрес действительно не покидает браузер; после включения это
|
||||||
|
|
@ -136,10 +152,17 @@ export default function MeraPublicPrivacyPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Автоматической кнопки «удалить мои данные» в сервисе пока нет, и мы не
|
Самостоятельной кнопки «удалить мои данные» в интерфейсе пока нет, но
|
||||||
обещаем то, чего не умеем: порядок и сроки удаления будут описаны в
|
механизм удаления в сервисе есть: обращение в поддержку об удалении мы
|
||||||
утверждённой политике обработки, которая появится здесь до открытия
|
разбираем вручную и физически стираем телефон, адрес и расчёт из базы,
|
||||||
публичного доступа.
|
а не просто помечаем запись. Помимо запроса, для каждого типа данных
|
||||||
|
установлен срок хранения, по истечении которого они подлежат
|
||||||
|
удалению. Если результат расчёта оплачен, ссылка на отчёт и
|
||||||
|
связанные с ним данные хранятся на нашей стороне{" "}
|
||||||
|
{PAID_REPORT_RETENTION_MONTHS} месяцев с даты оплаты, а затем
|
||||||
|
подлежат удалению так же, как и остальные данные — на файл, который
|
||||||
|
вы скачали себе, это не влияет: мы его не отзываем, не изменяем и не
|
||||||
|
имеем к нему доступа.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Оператор</h2>
|
<h2>Оператор</h2>
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ const brackets: { key: string; style: CSSProperties }[] = [
|
||||||
// Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there
|
// Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there
|
||||||
// is an estimate. Dashes — never the design fixtures (which would read as a fake
|
// is an estimate. Dashes — never the design fixtures (which would read as a fake
|
||||||
// real report).
|
// real report).
|
||||||
const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" };
|
const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—", retainUntil: null };
|
||||||
const EMPTY_OBJECT: ObjectInfo = {
|
const EMPTY_OBJECT: ObjectInfo = {
|
||||||
address: "—",
|
address: "—",
|
||||||
city: "",
|
city: "",
|
||||||
|
|
@ -652,7 +652,7 @@ export default function TradeInV2Page() {
|
||||||
|
|
||||||
// TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the
|
// TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the
|
||||||
// authoritative monthly counter; fall back to the loaded history length, then
|
// authoritative monthly counter; fall back to the loaded history length, then
|
||||||
// to the fixture default (undefined) for unwired/pre-load states.
|
// to undefined (TopNav renders 0) for pre-load states.
|
||||||
const reportsCount = quota.data?.used ?? history.data?.length;
|
const reportsCount = quota.data?.used ?? history.data?.length;
|
||||||
|
|
||||||
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
|
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export function CacheView({ data, onSelectRow }: CacheViewProps) {
|
||||||
// #2420 — clicking/activating a row navigates to the full report
|
// #2420 — clicking/activating a row navigates to the full report
|
||||||
// for that historical estimate (SectionOverlay -> page.tsx). Rows
|
// for that historical estimate (SectionOverlay -> page.tsx). Rows
|
||||||
// stay a passive role="row"/role="cell" ARIA-grid когда onSelectRow
|
// stay a passive role="row"/role="cell" ARIA-grid когда onSelectRow
|
||||||
// не задан (unwired/storybook usage), меняя только tabIndex/handlers
|
// не задан (SectionOverlay без обработчика), меняя только tabIndex/handlers
|
||||||
// — the grid structure itself is unchanged.
|
// — the grid structure itself is unchanged.
|
||||||
const clickable = Boolean(onSelectRow);
|
const clickable = Boolean(onSelectRow);
|
||||||
function activate() {
|
function activate() {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`.
|
// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`.
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { version } from "./fixtures";
|
import { version } from "./ui-config";
|
||||||
import type { Report } from "./types";
|
import type { Report } from "./types";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
|
|
@ -13,7 +13,7 @@ interface FooterProps {
|
||||||
// error, not a silent fallback to a fabricated report id/date.
|
// error, not a silent fallback to a fabricated report id/date.
|
||||||
data: Report;
|
data: Report;
|
||||||
// M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
|
// M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
|
||||||
// (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
|
// (mirrors HeroBar). Optional: omitted → the line stays visible.
|
||||||
hasEstimate?: boolean;
|
hasEstimate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import {
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { dropdownOptions } from "./fixtures";
|
import { dropdownOptions } from "./ui-config";
|
||||||
import {
|
import {
|
||||||
HOUSE_TYPE_FROM_RU,
|
HOUSE_TYPE_FROM_RU,
|
||||||
HOUSE_TYPE_RU,
|
HOUSE_TYPE_RU,
|
||||||
|
|
@ -541,8 +541,8 @@ const radiusPanel: CSSProperties = {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ParamsPanelProps {
|
interface ParamsPanelProps {
|
||||||
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional
|
||||||
* the still-unwired v2 page renders without props (tsc-safe). */
|
* dates back to the pre-wiring page; v2/page.tsx always supplies it. */
|
||||||
onSubmit?: (input: TradeInEstimateInput) => void;
|
onSubmit?: (input: TradeInEstimateInput) => void;
|
||||||
/** Disables the button + shows a pending label while the estimate is running. */
|
/** Disables the button + shows a pending label while the estimate is running. */
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { overlayNums, overlayTitles } from "./fixtures";
|
import { overlayNums, overlayTitles } from "./ui-config";
|
||||||
import HistoryView from "./HistoryView";
|
import HistoryView from "./HistoryView";
|
||||||
import SourcesView from "./SourcesView";
|
import SourcesView from "./SourcesView";
|
||||||
import AnalyticsView from "./AnalyticsView";
|
import AnalyticsView from "./AnalyticsView";
|
||||||
|
|
@ -37,7 +37,7 @@ interface SectionOverlayProps {
|
||||||
estimate?: AggregatedEstimate | null;
|
estimate?: AggregatedEstimate | null;
|
||||||
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
|
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
|
||||||
// that historical estimate. Optional (threaded straight to CacheView's own
|
// that historical estimate. Optional (threaded straight to CacheView's own
|
||||||
// optional onSelectRow — unwired usage leaves rows inert).
|
// optional onSelectRow — without a handler rows stay inert).
|
||||||
onSelectEstimate?: (id: string) => void;
|
onSelectEstimate?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,8 +139,9 @@ const monoMuted2: CSSProperties = {
|
||||||
color: tokens.muted2,
|
color: tokens.muted2,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared "open original ↗" cell style (anchor + fixture-fallback span render
|
// "Открыть оригинал ↗" cell style; textDecoration:none keeps the anchor
|
||||||
// identically; textDecoration:none keeps the anchor visually a button).
|
// visually a button. Only a real <a> ever gets it — a row without a link
|
||||||
|
// renders a plain dash, not a button-shaped span you cannot click.
|
||||||
const linkBtn: CSSProperties = {
|
const linkBtn: CSSProperties = {
|
||||||
fontSize: "9.5px",
|
fontSize: "9.5px",
|
||||||
color: tokens.accent,
|
color: tokens.accent,
|
||||||
|
|
@ -210,7 +211,7 @@ const AD_COLS: Col<AdRowData>[] = [
|
||||||
head: <span role="columnheader">ИСТОЧНИК</span>,
|
head: <span role="columnheader">ИСТОЧНИК</span>,
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<span role="cell">
|
<span role="cell">
|
||||||
<span style={badge}>{r.source ?? "Avito"}</span>
|
<span style={badge}>{r.source}</span>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
empty: () => false,
|
empty: () => false,
|
||||||
|
|
@ -263,7 +264,7 @@ const AD_COLS: Col<AdRowData>[] = [
|
||||||
head: <span role="columnheader">ДИСТ.</span>,
|
head: <span role="columnheader">ДИСТ.</span>,
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<span role="cell" style={monoMuted2}>
|
<span role="cell" style={monoMuted2}>
|
||||||
{r.dist ?? "—"}
|
{r.dist}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
empty: (r) => isDash(r.dist),
|
empty: (r) => isDash(r.dist),
|
||||||
|
|
@ -276,11 +277,10 @@ const AD_COLS: Col<AdRowData>[] = [
|
||||||
// role="cell" sits on a display:contents wrapper so the <a> stays the grid
|
// role="cell" sits on a display:contents wrapper so the <a> stays the grid
|
||||||
// item (identical layout) AND keeps its link role inside the cell.
|
// item (identical layout) AND keeps its link role inside the cell.
|
||||||
cell: (r) => {
|
cell: (r) => {
|
||||||
// safeUrl gates href against javascript:/data: schemes (XSS). url is
|
// safeUrl gates href against javascript:/data: schemes (XSS). url is a
|
||||||
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps the
|
// string when the listing has a link and null when it has none (→ "—").
|
||||||
// storybook markup pixel-identical), null for a real row with no link
|
// There is no third case: mapSources always sets the field.
|
||||||
// (→ "—"), and a string when a real listing URL is available.
|
const href = r.url === null ? null : safeUrl(r.url);
|
||||||
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
|
|
||||||
return (
|
return (
|
||||||
<span role="cell" style={{ display: "contents" }}>
|
<span role="cell" style={{ display: "contents" }}>
|
||||||
{href ? (
|
{href ? (
|
||||||
|
|
@ -293,12 +293,8 @@ const AD_COLS: Col<AdRowData>[] = [
|
||||||
>
|
>
|
||||||
Источник ↗
|
Источник ↗
|
||||||
</a>
|
</a>
|
||||||
) : r.url === null ? (
|
|
||||||
<span style={monoMuted2}>—</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="sv-rr" style={linkBtn}>
|
<span style={monoMuted2}>—</span>
|
||||||
Росреестр ↗
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -336,9 +332,9 @@ const DEAL_COLS: Col<DealRowData>[] = [
|
||||||
head: <span role="columnheader">ИСТОЧНИК</span>,
|
head: <span role="columnheader">ИСТОЧНИК</span>,
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<span role="cell">
|
<span role="cell">
|
||||||
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
|
<span style={badge}>{r.source}</span>{" "}
|
||||||
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
|
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
|
||||||
{r.tier ?? "по улице"}
|
{r.tier}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
|
|
@ -372,7 +368,7 @@ const DEAL_COLS: Col<DealRowData>[] = [
|
||||||
head: <span role="columnheader">ДАТА</span>,
|
head: <span role="columnheader">ДАТА</span>,
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<span role="cell" style={monoMuted2}>
|
<span role="cell" style={monoMuted2}>
|
||||||
{r.date ?? "—"}
|
{r.date}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
empty: (r) => isDash(r.date),
|
empty: (r) => isDash(r.date),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
||||||
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
|
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
|
||||||
// Tabs change only local UI state via onNavigate; the user dropdown owns its
|
// Tabs change only local UI state via onNavigate; the user dropdown owns its
|
||||||
// own useState. No data fetching — labels/version come from fixtures, the user
|
// own useState. No data fetching — labels/version come from ./ui-config, the user
|
||||||
// identity is fed in from the page (real useMe), colours from tokens.
|
// identity is fed in from the page (real useMe), colours from tokens.
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
@ -13,7 +13,7 @@ import type { CSSProperties } from "react";
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { navLabels, version } from "./fixtures";
|
import { navLabels, version } from "./ui-config";
|
||||||
import { useSupportChat } from "./SupportChatContext";
|
import { useSupportChat } from "./SupportChatContext";
|
||||||
|
|
||||||
// Real logged-in user identity, derived by the page from useMe()
|
// Real logged-in user identity, derived by the page from useMe()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
// #2755 — колонка ссылки в таблице аналогов «05 РЫНОК».
|
||||||
|
//
|
||||||
|
// Здесь до этого жила третья ветка: когда `url` приходил `undefined`, рисовался
|
||||||
|
// НЕкликабельный <span>, стилизованный под кнопку-ссылку «Росреестр ↗». Ветка
|
||||||
|
// была недостижима (mapSources всегда ставит `string | null`), но выглядела как
|
||||||
|
// рабочий элемент интерфейса. Ветку убрали, поле сделали обязательным — этот
|
||||||
|
// тест держит обе половины: ссылка есть только там, где есть URL, а строка без
|
||||||
|
// URL получает прочерк, а не кнопку-обманку.
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import SourcesView from "../SourcesView";
|
||||||
|
import { mapSources } from "../mappers";
|
||||||
|
import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture";
|
||||||
|
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
||||||
|
|
||||||
|
function lot(over: Partial<AnalogLot>): AnalogLot {
|
||||||
|
return {
|
||||||
|
address: "ул. Репина, 73",
|
||||||
|
area_m2: 54,
|
||||||
|
rooms: 2,
|
||||||
|
floor: 7,
|
||||||
|
total_floors: 16,
|
||||||
|
price_rub: 9_700_000,
|
||||||
|
price_per_m2: 179_629,
|
||||||
|
listing_date: "2026-05-12",
|
||||||
|
days_on_market: 18,
|
||||||
|
photo_url: null,
|
||||||
|
source: "avito",
|
||||||
|
source_url: null,
|
||||||
|
distance_m: 120,
|
||||||
|
tier: null,
|
||||||
|
lat: 56.8401,
|
||||||
|
lon: 60.5702,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оффлайн-фикстура оценки репозитория с подменёнными аналогами: остальные поля
|
||||||
|
// (sources_used, коридоры, KPI) настоящие, меняем ровно то, что проверяем.
|
||||||
|
function estimateWith(analogs: AnalogLot[]): AggregatedEstimate {
|
||||||
|
return {
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
analogs,
|
||||||
|
n_analogs: analogs.length,
|
||||||
|
actual_deals: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SourcesView — колонка ссылки на оригинал", () => {
|
||||||
|
it("есть URL — кликабельная ссылка на него", () => {
|
||||||
|
const estimate = estimateWith([
|
||||||
|
lot({
|
||||||
|
address: "ул. Репина, 73",
|
||||||
|
source_url: "https://www.avito.ru/ekaterinburg/kvartiry/1",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SourcesView data={mapSources(estimate, null)} />);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: /Источник/ });
|
||||||
|
expect(link).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry/1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL нет — прочерк, а не кнопка-обманка «Росреестр ↗»", () => {
|
||||||
|
const estimate = estimateWith([
|
||||||
|
lot({ address: "ул. Викулова, 33", source_url: null }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SourcesView data={mapSources(estimate, null)} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/Росреестр ↗/)).toBeNull();
|
||||||
|
expect(screen.queryByRole("link", { name: /Источник/ })).toBeNull();
|
||||||
|
// Прочерк — единственное содержимое ячейки ссылки в строке аналога.
|
||||||
|
const row = screen.getByText("ул. Викулова, 33").closest("div");
|
||||||
|
expect(row?.textContent).toContain("—");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("javascript:-схема отбрасывается safeUrl — прочерк, а не ссылка", () => {
|
||||||
|
const estimate = estimateWith([
|
||||||
|
lot({ address: "ул. Кирова, 28", source_url: "javascript:alert(1)" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SourcesView data={mapSources(estimate, null)} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("link", { name: /Источник/ })).toBeNull();
|
||||||
|
const row = screen.getByText("ул. Кирова, 28").closest("div");
|
||||||
|
expect(row?.textContent).toContain("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,671 +0,0 @@
|
||||||
// Sample/markup data for the /trade-in/v2 "МЕРА Оценка" design port.
|
|
||||||
// Extracted verbatim from the pixel design (МЕРА Оценка.dc.html). This is the
|
|
||||||
// data source for the markup — components must NOT fetch; they read from here.
|
|
||||||
// Colour fields reference tokens so the palette stays the single source of truth.
|
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
|
||||||
import type {
|
|
||||||
Analytics,
|
|
||||||
CacheKpi,
|
|
||||||
CacheRow,
|
|
||||||
DkpRow,
|
|
||||||
AdRow,
|
|
||||||
DealRow,
|
|
||||||
DropdownOptions,
|
|
||||||
History,
|
|
||||||
MarketAds,
|
|
||||||
MarketDeals,
|
|
||||||
ObjectInfo,
|
|
||||||
Ranges,
|
|
||||||
Report,
|
|
||||||
ResultCard,
|
|
||||||
ResultMeta,
|
|
||||||
ScatterMini,
|
|
||||||
SourceCard,
|
|
||||||
Summary,
|
|
||||||
User,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export const report: Report = {
|
|
||||||
id: "9b9e2737",
|
|
||||||
date: "20.06.2026",
|
|
||||||
validUntil: "21.06.2026",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const object: ObjectInfo = {
|
|
||||||
address: "ул. Малышева, 30",
|
|
||||||
city: "Екатеринбург, Россия",
|
|
||||||
area: "54",
|
|
||||||
rooms: "2",
|
|
||||||
floor: "5",
|
|
||||||
totalFloors: "17",
|
|
||||||
year: "1985",
|
|
||||||
houseType: "Панельный",
|
|
||||||
repair: "Хороший",
|
|
||||||
balcony: true,
|
|
||||||
locationIndexLabel: "+8%",
|
|
||||||
locationIndexOk: true,
|
|
||||||
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
|
|
||||||
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
|
|
||||||
lat: 56.8384,
|
|
||||||
lon: 60.6057,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- 02 RESULT ------------------------------------------------------------
|
|
||||||
|
|
||||||
export const resultCards: ResultCard[] = [
|
|
||||||
{
|
|
||||||
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
|
||||||
value: "11,29",
|
|
||||||
unit: "млн ₽",
|
|
||||||
range: "7,72 — 14,46 млн ₽",
|
|
||||||
ppm: "225 801 ₽/м² · по объявлениям",
|
|
||||||
bars: [30, 55, 80, 100, 70, 45, 25, 35],
|
|
||||||
nav: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"],
|
|
||||||
value: "10,00",
|
|
||||||
unit: "млн ₽",
|
|
||||||
range: "6,83 — 12,81 млн ₽",
|
|
||||||
ppm: "199 973 ₽/м²",
|
|
||||||
delta: "−11%",
|
|
||||||
deltaLabel: "К ОБЪЯВЛ.",
|
|
||||||
nav: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
|
|
||||||
value: "9,43",
|
|
||||||
unit: "млн ₽",
|
|
||||||
range: "6,10 — 12,72 млн ₽",
|
|
||||||
ppm: "188 672 ₽/м² · 44 сделки",
|
|
||||||
bars: [25, 45, 70, 100, 85, 55, 30, 20],
|
|
||||||
nav: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const resultMeta: ResultMeta = {
|
|
||||||
sources: "3 / 7",
|
|
||||||
confidence: "СРЕДНЯЯ",
|
|
||||||
cv: "59.7%",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ranges: Ranges = {
|
|
||||||
ads: {
|
|
||||||
label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
|
|
||||||
median: "11,29 млн ₽",
|
|
||||||
lo: "7,72 млн",
|
|
||||||
hi: "14,46 млн",
|
|
||||||
marker: { fillLeft: "8%", fillRight: "18%", dotLeft: "58%" },
|
|
||||||
},
|
|
||||||
deals: {
|
|
||||||
label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"],
|
|
||||||
median: "6,80 млн ₽",
|
|
||||||
lo: "4,30 млн",
|
|
||||||
hi: "19,70 млн",
|
|
||||||
marker: { fillLeft: "4%", fillRight: "55%", dotLeft: "33%" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mini "ЦЕНА × СРОК ПРОДАЖИ" scatter inside the ranges card (viewBox 168×140).
|
|
||||||
export const scatterMini: ScatterMini = {
|
|
||||||
deals: [
|
|
||||||
{ x: 52, y: 96, r: 2.4 },
|
|
||||||
{ x: 64, y: 88, r: 2.4 },
|
|
||||||
{ x: 58, y: 101, r: 2.4 },
|
|
||||||
{ x: 74, y: 85, r: 2.4 },
|
|
||||||
{ x: 47, y: 103, r: 2.4 },
|
|
||||||
{ x: 88, y: 79, r: 2.4 },
|
|
||||||
{ x: 70, y: 93, r: 2.4 },
|
|
||||||
{ x: 55, y: 90, r: 2.4 },
|
|
||||||
{ x: 82, y: 99, r: 2.4 },
|
|
||||||
{ x: 44, y: 97, r: 2.4 },
|
|
||||||
],
|
|
||||||
analogs: [
|
|
||||||
{ x: 118, y: 48, r: 3 },
|
|
||||||
{ x: 100, y: 60, r: 3 },
|
|
||||||
{ x: 132, y: 40, r: 3 },
|
|
||||||
{ x: 92, y: 66, r: 3 },
|
|
||||||
{ x: 112, y: 54, r: 3 },
|
|
||||||
{ x: 142, y: 34, r: 3 },
|
|
||||||
],
|
|
||||||
subject: { x: 96, y: 70, r: 3.6 },
|
|
||||||
yTicks: [
|
|
||||||
{ label: "15М", y: 13 },
|
|
||||||
{ label: "10М", y: 77 },
|
|
||||||
{ label: "4М", y: 112 },
|
|
||||||
],
|
|
||||||
xTicks: [
|
|
||||||
{ label: "0", x: 34 },
|
|
||||||
{ label: "90", x: 96 },
|
|
||||||
{ label: "180", x: 158 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sources: SourceCard[] = [
|
|
||||||
{ name: "ЦИАН", count: "7", label: "лотов", active: true },
|
|
||||||
{ name: "Я.НЕДВИЖИМОСТЬ", count: "1", label: "лот", active: true },
|
|
||||||
{ name: "РОСРЕЕСТР (ВНУТР.)", count: "10", label: "лотов", active: true },
|
|
||||||
{ name: "АВИТО ОЦЕНКА", count: "—", label: "нет данных", active: false },
|
|
||||||
{ name: "ДОМКЛИК ПРАЙС", count: "—", label: "нет данных", active: false },
|
|
||||||
{ name: "RESTATE", count: "—", label: "нет данных", active: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---- 03 SUMMARY -----------------------------------------------------------
|
|
||||||
|
|
||||||
export const summary: Summary = {
|
|
||||||
rows: [
|
|
||||||
{ label: "Продажи в доме", value: "2 · 32 дн", dot: "accent", nav: 1 },
|
|
||||||
{ label: "Аналоги", value: "23 · 6,13 млн", dot: "accent", nav: 2 },
|
|
||||||
{ label: "Сделки", value: "10 · 5,95 млн", dot: "muted", nav: 2 },
|
|
||||||
{ label: "Аналитика", value: "67 дн · −3%", dot: "accent", nav: 3 },
|
|
||||||
],
|
|
||||||
quality: { sources: "3 / 7", confidence: "средняя", cv: "59.7%" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- OVERLAY 04: ПРОДАЖИ В ДОМЕ -------------------------------------------
|
|
||||||
|
|
||||||
export const dkpRows: DkpRow[] = [
|
|
||||||
{
|
|
||||||
area: "33.9 м²",
|
|
||||||
price: "5 950 000 ₽",
|
|
||||||
ppm: "175 516",
|
|
||||||
ask: "5 272 800 ₽ за 3 дн до",
|
|
||||||
delta: "+12.8%",
|
|
||||||
deltaColor: tokens.danger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "35.4 м²",
|
|
||||||
price: "4 900 000 ₽",
|
|
||||||
ppm: "138 418",
|
|
||||||
ask: "5 186 700 ₽ за 3 дн до",
|
|
||||||
delta: "−5.5%",
|
|
||||||
deltaColor: tokens.success,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "36.2 м²",
|
|
||||||
price: "4 200 000 ₽",
|
|
||||||
ppm: "116 022",
|
|
||||||
ask: "5 186 700 ₽ за 3 дн до",
|
|
||||||
delta: "−19.0%",
|
|
||||||
deltaColor: tokens.success,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "37.0 м²",
|
|
||||||
price: "7 503 227 ₽",
|
|
||||||
ppm: "202 790",
|
|
||||||
ask: "5 272 800 ₽ за 3 дн до",
|
|
||||||
delta: "+42.3%",
|
|
||||||
deltaColor: tokens.danger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "36.8 м²",
|
|
||||||
price: "7 140 243 ₽",
|
|
||||||
ppm: "194 028",
|
|
||||||
ask: "5 272 800 ₽ за 3 дн до",
|
|
||||||
delta: "+35.4%",
|
|
||||||
deltaColor: tokens.danger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "42.1 м²",
|
|
||||||
price: "6 535 050 ₽",
|
|
||||||
ppm: "155 227",
|
|
||||||
ask: "5 272 800 ₽ за 3 дн до",
|
|
||||||
delta: "+23.9%",
|
|
||||||
deltaColor: tokens.danger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "42.0 м²",
|
|
||||||
price: "6 650 000 ₽",
|
|
||||||
ppm: "158 333",
|
|
||||||
ask: "5 781 600 ₽ за 3 дн до",
|
|
||||||
delta: "+15.0%",
|
|
||||||
deltaColor: tokens.danger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "32.5 м²",
|
|
||||||
price: "5 362 650 ₽",
|
|
||||||
ppm: "165 005",
|
|
||||||
ask: "5 325 600 ₽ за 3 дн до",
|
|
||||||
delta: "+0.7%",
|
|
||||||
deltaColor: tokens.danger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "38.8 м²",
|
|
||||||
price: "4 380 000 ₽",
|
|
||||||
ppm: "112 887",
|
|
||||||
ask: "5 325 600 ₽ за 3 дн до",
|
|
||||||
delta: "−17.8%",
|
|
||||||
deltaColor: tokens.success,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "34.9 м²",
|
|
||||||
price: "5 000 000 ₽",
|
|
||||||
ppm: "143 266",
|
|
||||||
ask: "5 254 500 ₽ за 3 дн до",
|
|
||||||
delta: "−4.8%",
|
|
||||||
deltaColor: tokens.success,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const history: History = {
|
|
||||||
houseSales: [
|
|
||||||
{
|
|
||||||
lot: "2-к. квартира, 50 м², 6/16 эт.",
|
|
||||||
priceFrom: "6,25",
|
|
||||||
priceTo: "5,98 млн ₽",
|
|
||||||
date: "11.07.2025",
|
|
||||||
exposure: "32 дн.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lot: "2-к. квартира, 60 м², 6/16 эт.",
|
|
||||||
priceFrom: "7,29",
|
|
||||||
priceTo: "7,09 млн ₽",
|
|
||||||
date: "11.07.2025",
|
|
||||||
exposure: "31 дн.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dkpHeader: {
|
|
||||||
title: "ДКП-сделки на улице Академика Ландау",
|
|
||||||
period: "24 мес",
|
|
||||||
note: "78% сделок имеют историческую цену в объявлении · медианный торг −36.0%. Данные по улице, не по дому.",
|
|
||||||
},
|
|
||||||
dkpKpi: { count: "95", median: "140 066", range: "3,1 – 7,5" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- OVERLAY 05: АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ -----------------------------------
|
|
||||||
|
|
||||||
export const adRows: AdRow[] = [
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Ландау, 39",
|
|
||||||
meta: "32.5 м² · 1-к · этаж 12/15",
|
|
||||||
ppm: "170 769",
|
|
||||||
price: "5 550 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Ландау, 35А/2",
|
|
||||||
meta: "33.5 м² · 1-к · этаж 13/25",
|
|
||||||
ppm: "174 626",
|
|
||||||
price: "5 850 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Ландау, 38",
|
|
||||||
meta: "32.7 м² · 1-к · этаж 10/16",
|
|
||||||
ppm: "171 253",
|
|
||||||
price: "5 600 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Ландау, 35",
|
|
||||||
meta: "32.7 м² · 1-к · этаж 6/12",
|
|
||||||
ppm: "178 999",
|
|
||||||
price: "5 853 300",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Ландау, 35",
|
|
||||||
meta: "37.4 м² · 1-к · этаж 8/12",
|
|
||||||
ppm: "149 732",
|
|
||||||
price: "5 600 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Парина, 29",
|
|
||||||
meta: "40.5 м² · 1-к · этаж 22/23",
|
|
||||||
ppm: "160 493",
|
|
||||||
price: "6 500 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Ландау, 39",
|
|
||||||
meta: "32.5 м² · 1-к · этаж 15/15",
|
|
||||||
ppm: "146 153",
|
|
||||||
price: "4 750 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Ландау, 39",
|
|
||||||
meta: "34.9 м² · 1-к · этаж 9/15",
|
|
||||||
ppm: "143 266",
|
|
||||||
price: "5 000 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Парина, 33",
|
|
||||||
meta: "37.1 м² · 1-к · этаж 6/14",
|
|
||||||
ppm: "160 916",
|
|
||||||
price: "5 970 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "ул. Академика Ландау, 34",
|
|
||||||
meta: "41.7 м² · 1-к · этаж 22/23",
|
|
||||||
ppm: "167 865",
|
|
||||||
price: "6 999 999",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const dealRows: DealRow[] = [
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Парина",
|
|
||||||
meta: "38.1 м² · 1-к",
|
|
||||||
ppm: "161 417",
|
|
||||||
price: "6 150 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Ландау",
|
|
||||||
meta: "33.9 м² · 1-к",
|
|
||||||
ppm: "175 516",
|
|
||||||
price: "5 950 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Ландау",
|
|
||||||
meta: "39.1 м² · 1-к",
|
|
||||||
ppm: "148 338",
|
|
||||||
price: "5 800 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Парина",
|
|
||||||
meta: "33.8 м² · 1-к",
|
|
||||||
ppm: "147 929",
|
|
||||||
price: "5 000 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Сахарова",
|
|
||||||
meta: "37.1 м² · 1-к",
|
|
||||||
ppm: "167 116",
|
|
||||||
price: "6 200 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Вавилова",
|
|
||||||
meta: "38.8 м² · 1-к",
|
|
||||||
ppm: "165 437",
|
|
||||||
price: "6 418 972",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Ландау",
|
|
||||||
meta: "42.0 м² · 1-к",
|
|
||||||
ppm: "158 333",
|
|
||||||
price: "6 650 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Ландау",
|
|
||||||
meta: "34.9 м² · 1-к",
|
|
||||||
ppm: "143 266",
|
|
||||||
price: "5 000 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Парина",
|
|
||||||
meta: "32.9 м² · 1-к",
|
|
||||||
ppm: "159 574",
|
|
||||||
price: "5 250 000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
addr: "Екатеринбург, Академика Парина",
|
|
||||||
meta: "41.2 м² · 1-к",
|
|
||||||
ppm: "101 942",
|
|
||||||
price: "4 200 000",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const marketAds: MarketAds = {
|
|
||||||
kpi: {
|
|
||||||
count: "23",
|
|
||||||
median: "6,13",
|
|
||||||
ppm: "161 351",
|
|
||||||
range: "4,7–7,5",
|
|
||||||
cv: "45.9%",
|
|
||||||
},
|
|
||||||
filters: ["Расстояние ≤ 2 км", "Планировка 1-к", "Площадь 32.5 – 41.7 м²"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const marketDeals: MarketDeals = {
|
|
||||||
kpi: {
|
|
||||||
count: "10",
|
|
||||||
median: "5,95",
|
|
||||||
delta: "−3% к рынку",
|
|
||||||
range: "4,2 – 6,7",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- OVERLAY 06: АНАЛИТИКА ДОМА -------------------------------------------
|
|
||||||
|
|
||||||
export const analytics: Analytics = {
|
|
||||||
header: { ads: "615 объявлений", radius: "в радиусе 300 м" },
|
|
||||||
kpis: [
|
|
||||||
{
|
|
||||||
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
|
|
||||||
value: "67",
|
|
||||||
unit: "дн.",
|
|
||||||
sub: "по архивным объявлениям",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "СРЕДНИЙ ТОРГ",
|
|
||||||
value: "−3.0%",
|
|
||||||
color: tokens.success,
|
|
||||||
sub: "от начальной до итоговой цены",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "ДОЛЯ СНЯТЫХ",
|
|
||||||
value: "69%",
|
|
||||||
sub: "421 из 615",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sellTimeNote: "Медиана экспозиции по архивным лотам · эталон 177 394 ₽/м²",
|
|
||||||
sellTime: [
|
|
||||||
{
|
|
||||||
tier: "−5% от рынка",
|
|
||||||
days: "~63 дн.",
|
|
||||||
range: "обычно 18–180 дн.",
|
|
||||||
count: "69 аналогов",
|
|
||||||
variant: "success",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: "По медиане",
|
|
||||||
days: "~65 дн.",
|
|
||||||
range: "обычно 18–182 дн.",
|
|
||||||
count: "79 аналогов",
|
|
||||||
variant: "accent",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: "+5%",
|
|
||||||
days: "~78 дн.",
|
|
||||||
range: "обычно 16–212 дн.",
|
|
||||||
count: "61 аналог",
|
|
||||||
variant: "gold",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: "+10%",
|
|
||||||
days: "~67 дн.",
|
|
||||||
range: "обычно 26–260 дн.",
|
|
||||||
count: "57 аналогов",
|
|
||||||
variant: "danger",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
priceHistory: {
|
|
||||||
note: "Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (596 лотов)",
|
|
||||||
years: ["2022", "2023", "2024", "2025", "2026"],
|
|
||||||
avito: "58,75 260,82 470,69 680,69 885,73",
|
|
||||||
yandex: "58,75 260,82 470,51 680,42 885,24",
|
|
||||||
yTicks: [
|
|
||||||
{ label: "200k", y: 20 },
|
|
||||||
{ label: "150k", y: 64 },
|
|
||||||
{ label: "100k", y: 108 },
|
|
||||||
{ label: "50k", y: 151 },
|
|
||||||
{ label: "0k", y: 195 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
scatterDetail: {
|
|
||||||
note: "16 сделок и 6 аналогов в радиусе 300 м",
|
|
||||||
deals: [
|
|
||||||
{ x: 150, y: 210, r: 4.5 },
|
|
||||||
{ x: 200, y: 196, r: 4.5 },
|
|
||||||
{ x: 175, y: 222, r: 4.5 },
|
|
||||||
{ x: 250, y: 188, r: 4.5 },
|
|
||||||
{ x: 220, y: 204, r: 4.5 },
|
|
||||||
{ x: 300, y: 176, r: 4.5 },
|
|
||||||
{ x: 270, y: 200, r: 4.5 },
|
|
||||||
{ x: 330, y: 166, r: 4.5 },
|
|
||||||
{ x: 360, y: 182, r: 4.5 },
|
|
||||||
{ x: 195, y: 214, r: 4.5 },
|
|
||||||
{ x: 285, y: 190, r: 4.5 },
|
|
||||||
{ x: 160, y: 200, r: 4.5 },
|
|
||||||
{ x: 345, y: 198, r: 4.5 },
|
|
||||||
{ x: 240, y: 172, r: 4.5 },
|
|
||||||
{ x: 315, y: 186, r: 4.5 },
|
|
||||||
{ x: 180, y: 232, r: 4.5 },
|
|
||||||
],
|
|
||||||
analogs: [
|
|
||||||
{ x: 600, y: 96, r: 5.5 },
|
|
||||||
{ x: 540, y: 124, r: 5.5 },
|
|
||||||
{ x: 670, y: 84, r: 5.5 },
|
|
||||||
{ x: 500, y: 142, r: 5.5 },
|
|
||||||
{ x: 640, y: 104, r: 5.5 },
|
|
||||||
{ x: 730, y: 70, r: 5.5 },
|
|
||||||
],
|
|
||||||
subject: { x: 445, y: 150, r: 6 },
|
|
||||||
yTicks: [
|
|
||||||
{ label: "15М", y: 24 },
|
|
||||||
{ label: "12М", y: 81 },
|
|
||||||
{ label: "9М", y: 138 },
|
|
||||||
{ label: "6М", y: 195 },
|
|
||||||
{ label: "4М", y: 252 },
|
|
||||||
],
|
|
||||||
xTicks: [
|
|
||||||
{ label: "0", x: 60 },
|
|
||||||
{ label: "45", x: 265 },
|
|
||||||
{ label: "90", x: 470 },
|
|
||||||
{ label: "135", x: 675 },
|
|
||||||
{ label: "180", x: 880 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ ----------------------------------------
|
|
||||||
|
|
||||||
export const cacheRows: CacheRow[] = [
|
|
||||||
{
|
|
||||||
id: "fixture-1",
|
|
||||||
addr: "ул. Малышева, 30",
|
|
||||||
time: "12:45:02",
|
|
||||||
src: "3 / 7",
|
|
||||||
status: "свежий",
|
|
||||||
statusColor: tokens.success,
|
|
||||||
params: "2 комн · 54 м² · 5/9 эт.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fixture-2",
|
|
||||||
addr: "ул. Куйбышева, 48",
|
|
||||||
time: "12:31:50",
|
|
||||||
src: "4 / 7",
|
|
||||||
status: "свежий",
|
|
||||||
statusColor: tokens.success,
|
|
||||||
params: "3 комн · 72 м² · 12/16 эт.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fixture-3",
|
|
||||||
addr: "ул. Академика Ландау, 35",
|
|
||||||
time: "11:58:14",
|
|
||||||
src: "2 / 7",
|
|
||||||
status: "свежий",
|
|
||||||
statusColor: tokens.success,
|
|
||||||
params: "1 комн · 38 м²",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fixture-4",
|
|
||||||
addr: "ул. 8 Марта, 120",
|
|
||||||
time: "09:12:40",
|
|
||||||
src: "3 / 7",
|
|
||||||
status: "устаревает",
|
|
||||||
statusColor: tokens.warn,
|
|
||||||
params: "студия · 24 м² · 3/5 эт.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fixture-5",
|
|
||||||
addr: "пр. Ленина, 5",
|
|
||||||
time: "вчера 18:04",
|
|
||||||
src: "5 / 7",
|
|
||||||
status: "устарел",
|
|
||||||
statusColor: tokens.danger,
|
|
||||||
params: "2 комн · 61,5 м²",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fixture-6",
|
|
||||||
addr: "ул. Белинского, 86",
|
|
||||||
time: "вчера 14:22",
|
|
||||||
src: "2 / 7",
|
|
||||||
status: "устарел",
|
|
||||||
statusColor: tokens.danger,
|
|
||||||
params: "4 комн · 95 м² · 7/10 эт.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const cacheKpi: CacheKpi[] = [
|
|
||||||
{ label: "ВСЕГО ОЦЕНОК", value: "128", sub: "за последние 30 дней" },
|
|
||||||
{
|
|
||||||
label: "СРЕДНЯЯ ЦЕНА ОЦЕНОК",
|
|
||||||
value: "9,6",
|
|
||||||
unit: "млн ₽",
|
|
||||||
sub: "по проведённым оценкам",
|
|
||||||
},
|
|
||||||
{ label: "ПОВТОРНЫЕ АДРЕСА", value: "18", unit: "%", sub: "доля переоценок" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---- INPUTS / DROPDOWNS ---------------------------------------------------
|
|
||||||
|
|
||||||
export const dropdownOptions: DropdownOptions = {
|
|
||||||
rooms: ["1", "2", "3", "4", "5+"],
|
|
||||||
houseType: [
|
|
||||||
"Не указано",
|
|
||||||
"Панельный",
|
|
||||||
"Кирпичный",
|
|
||||||
"Монолитный",
|
|
||||||
"Блочный",
|
|
||||||
"Монолитно-кирпичный",
|
|
||||||
],
|
|
||||||
repair: [
|
|
||||||
"Не указано",
|
|
||||||
"Без отделки",
|
|
||||||
"Требует ремонта",
|
|
||||||
"Удовлетворительный",
|
|
||||||
"Хороший",
|
|
||||||
"Отличный",
|
|
||||||
],
|
|
||||||
radius: ["300 м", "500 м", "1000 м", "2000 м"],
|
|
||||||
crm: [
|
|
||||||
"Не заполнять",
|
|
||||||
"Создать сделку",
|
|
||||||
"Привязать к контакту",
|
|
||||||
"Экспорт в CRM",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient
|
|
||||||
// + base/coef/result formula + positives/negatives) lived here — removed
|
|
||||||
// together with the location-index rewrite. LocationDrawer now reads
|
|
||||||
// `LocationData` produced by `mapLocation` (./mappers.ts) from the real
|
|
||||||
// GET /trade-in/location-index response; its own default/unwired fixture is
|
|
||||||
// LOCATION_FIXTURE in ./LocationDrawer.tsx.
|
|
||||||
|
|
||||||
// ---- NAV / CHROME ---------------------------------------------------------
|
|
||||||
|
|
||||||
export const navLabels: string[] = [
|
|
||||||
"ОЦЕНКА",
|
|
||||||
"ПРОДАЖИ В ДОМЕ",
|
|
||||||
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
|
|
||||||
"АНАЛИТИКА",
|
|
||||||
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const overlayTitles: string[] = [
|
|
||||||
"",
|
|
||||||
"ПРОДАЖИ В ДОМЕ",
|
|
||||||
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
|
|
||||||
"АНАЛИТИКА ДОМА",
|
|
||||||
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const overlayNums: string[] = ["", "04", "05", "06", "07"];
|
|
||||||
|
|
||||||
export const user: User = {
|
|
||||||
name: "Андрей Петров",
|
|
||||||
org: "Брусника",
|
|
||||||
email: "a.petrov@mera.ru",
|
|
||||||
initials: "АП",
|
|
||||||
reports: 128,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const version = "v2.0.6";
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
// Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port.
|
// Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port.
|
||||||
//
|
//
|
||||||
// These functions take the real trade-in API objects (AggregatedEstimate,
|
// These functions take the real trade-in API objects (AggregatedEstimate,
|
||||||
// StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture
|
// StreetDealsResponse, HouseAnalyticsResponse) and produce the display shapes
|
||||||
// shapes the v2 components already consume (see ./types + ./fixtures). The Wire
|
// the v2 components consume (see ./types). Every component's `data` prop is
|
||||||
// phase swaps each component's hardcoded fixture for a `data` prop fed by one of
|
// REQUIRED and fed by one of these mappers — since #2747 there is no fixture
|
||||||
// these mappers; the fixtures stay as the prop DEFAULT so unwired usage still
|
// default to fall back on, and since #2755 there are no data fixtures at all
|
||||||
// renders. ALL money/format/geometry lives here — never copy fixture literals.
|
// (./ui-config keeps only labels/options/version). ALL money/format/geometry
|
||||||
|
// lives here; a value the API did not supply is rendered as a dash, never
|
||||||
|
// substituted with a plausible-looking constant.
|
||||||
//
|
//
|
||||||
// Reconciliations / known FE-approximations (look for "TODO BE-N"):
|
// Reconciliations / known FE-approximations (look for "TODO BE-N"):
|
||||||
// BE-1 #2043 shipped 2026-07-02: AggregatedEstimate now carries real
|
// BE-1 #2043 shipped 2026-07-02: AggregatedEstimate now carries real
|
||||||
|
|
@ -780,6 +782,9 @@ export function mapReport(e: AggregatedEstimate): Report {
|
||||||
? fmtDate(e.created_at)
|
? fmtDate(e.created_at)
|
||||||
: fmtDateShift(e.expires_at, -24), // pre-BE-1 fallback: no real created_at
|
: fmtDateShift(e.expires_at, -24), // pre-BE-1 fallback: no real created_at
|
||||||
validUntil: fmtDate(e.expires_at),
|
validUntil: fmtDate(e.expires_at),
|
||||||
|
// PR-D1: passthrough only, validUntil above stays on expires_at (this
|
||||||
|
// field is the paid-access retention date, unrelated to report validity).
|
||||||
|
retainUntil: e.retain_until ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1294,9 +1299,9 @@ export function mapSummary(
|
||||||
//
|
//
|
||||||
// Three pure mappers feeding the SectionOverlay views. Each returns a composite
|
// Three pure mappers feeding the SectionOverlay views. Each returns a composite
|
||||||
// presentation shape whose fields keep the exact ./types contract the views
|
// presentation shape whose fields keep the exact ./types contract the views
|
||||||
// already render, so the Wire phase only swaps each view's fixture import for a
|
// render. The `data` prop is required — the views had fixture imports until the
|
||||||
// `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed
|
// Wire phase and fixture DEFAULTS until #2747; both are gone. All number/format/
|
||||||
// here from the real API objects — never copied from fixtures. Graceful nulls
|
// geometry is recomputed here from the real API objects. Graceful nulls
|
||||||
// degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay.
|
// degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay.
|
||||||
//
|
//
|
||||||
// NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/
|
// NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/
|
||||||
|
|
@ -1966,26 +1971,31 @@ export function mapAnalytics(
|
||||||
// ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ───────────────────────────────
|
// ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ───────────────────────────────
|
||||||
|
|
||||||
// Real per-row source/distance/url + tier/date carried alongside the existing
|
// Real per-row source/distance/url + tier/date carried alongside the existing
|
||||||
// display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid
|
// display fields. REQUIRED: mapSources is the only producer of these rows and
|
||||||
// as the component DEFAULT; the mapper always populates them.
|
// always populates every field. They used to be optional so the deleted design
|
||||||
|
// fixtures (AdRow[]/DealRow[]) would typecheck as the component DEFAULT — and
|
||||||
|
// that optionality is what let SourcesView keep unreachable `?? "Avito"` /
|
||||||
|
// `?? "Росреестр"` / "Росреестр ↗" fallbacks that looked like real data (#2755).
|
||||||
|
// Keep them required: an absent value must be a TS error here, not an invented
|
||||||
|
// brand name on screen.
|
||||||
export interface AdRowData extends AdRow {
|
export interface AdRowData extends AdRow {
|
||||||
/** Source brand label for the badge (was hardcoded "Avito"). */
|
/** Source brand label for the badge. */
|
||||||
source?: string;
|
source: string;
|
||||||
/** Distance to the subject (was hardcoded "—"). */
|
/** Distance to the subject; "—" when unknown. */
|
||||||
dist?: string;
|
dist: string;
|
||||||
/** Original listing URL (was a hardcoded "Росреестр ↗" link). */
|
/** Original listing URL; null when the row has no link. */
|
||||||
url?: string | null;
|
url: string | null;
|
||||||
/** Fix #8 — ₽/м² > OUTLIER_MULT× the pool median: excluded from spread/CV,
|
/** Fix #8 — ₽/м² > OUTLIER_MULT× the pool median: excluded from spread/CV,
|
||||||
* flagged with a "возможный выброс" badge instead of silently distorting it. */
|
* flagged with a "возможный выброс" badge instead of silently distorting it. */
|
||||||
outlier?: boolean;
|
outlier: boolean;
|
||||||
}
|
}
|
||||||
export interface DealRowData extends DealRow {
|
export interface DealRowData extends DealRow {
|
||||||
/** Source brand label for the badge (was hardcoded "Росреестр"). */
|
/** Source brand label for the badge. */
|
||||||
source?: string;
|
source: string;
|
||||||
/** Tier caption next to the badge (was hardcoded "по улице"). */
|
/** Tier caption next to the badge. */
|
||||||
tier?: string;
|
tier: string;
|
||||||
/** Deal/listing date (was hardcoded "01.01.26"). */
|
/** Deal/listing date; "—" when unknown. */
|
||||||
date?: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SourcesData {
|
export interface SourcesData {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
// TypeScript shapes for the /trade-in/v2 design fixtures.
|
// Display shapes rendered by the /trade-in/v2 components. Not API types — the
|
||||||
// All values are sample/markup data (see fixtures.ts) — no API types here.
|
// wire contract lives in @/types/trade-in and is converted here by ./mappers.
|
||||||
|
// Originally these described the pixel-design fixtures; the fixtures are gone
|
||||||
|
// (#2755), the shapes stayed because the mappers produce them.
|
||||||
|
|
||||||
export interface Report {
|
export interface Report {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
validUntil: string;
|
validUntil: string;
|
||||||
|
// PR-D1: raw ISO retain_until passthrough (paid-access link lifetime), null
|
||||||
|
// when unpaid (current traffic). NOT rendered yet -- no payment UI in this
|
||||||
|
// PR; validUntil stays wired to expires_at, unrelated to this field.
|
||||||
|
retainUntil: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ObjectInfo {
|
export interface ObjectInfo {
|
||||||
|
|
@ -314,12 +320,7 @@ export interface DropdownOptions {
|
||||||
// (see ./mappers.ts), which reflects the real backend contract, not the old
|
// (see ./mappers.ts), which reflects the real backend contract, not the old
|
||||||
// fabricated formula.
|
// fabricated formula.
|
||||||
|
|
||||||
// ---- USER / CHROME --------------------------------------------------------
|
// Note: the `User` shape («Андрей Петров» / «Брусника» / reports: 128) lived
|
||||||
|
// here for the fixture of the same name — both deleted in #2755. The real
|
||||||
export interface User {
|
// logged-in identity is `TopNavUser` in ./TopNav.tsx, fed from useMe(); a
|
||||||
name: string;
|
// missing user renders the neutral «Гость», never a fabricated person.
|
||||||
org: string;
|
|
||||||
email: string;
|
|
||||||
initials: string;
|
|
||||||
reports: number;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
62
tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts
Normal file
62
tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// Static UI chrome for the /trade-in/v2 "МЕРА Оценка" port: nav/overlay labels,
|
||||||
|
// select options, build version. Constants only — no numbers that could be
|
||||||
|
// mistaken for measurements.
|
||||||
|
//
|
||||||
|
// This file used to be `fixtures.ts` and carried ~600 lines of made-up prices,
|
||||||
|
// deal rows and analytics lifted from the pixel design. Those were the prop
|
||||||
|
// DEFAULTS until #2747 made every data prop required; #2755 deleted them. All
|
||||||
|
// displayed data now comes from the API through ./mappers — if a value is
|
||||||
|
// missing the UI shows an honest dash, never a plausible-looking number.
|
||||||
|
|
||||||
|
import type { DropdownOptions } from "./types";
|
||||||
|
|
||||||
|
// ---- INPUTS / DROPDOWNS ---------------------------------------------------
|
||||||
|
|
||||||
|
export const dropdownOptions: DropdownOptions = {
|
||||||
|
rooms: ["1", "2", "3", "4", "5+"],
|
||||||
|
houseType: [
|
||||||
|
"Не указано",
|
||||||
|
"Панельный",
|
||||||
|
"Кирпичный",
|
||||||
|
"Монолитный",
|
||||||
|
"Блочный",
|
||||||
|
"Монолитно-кирпичный",
|
||||||
|
],
|
||||||
|
repair: [
|
||||||
|
"Не указано",
|
||||||
|
"Без отделки",
|
||||||
|
"Требует ремонта",
|
||||||
|
"Удовлетворительный",
|
||||||
|
"Хороший",
|
||||||
|
"Отличный",
|
||||||
|
],
|
||||||
|
radius: ["300 м", "500 м", "1000 м", "2000 м"],
|
||||||
|
crm: [
|
||||||
|
"Не заполнять",
|
||||||
|
"Создать сделку",
|
||||||
|
"Привязать к контакту",
|
||||||
|
"Экспорт в CRM",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- NAV / CHROME ---------------------------------------------------------
|
||||||
|
|
||||||
|
export const navLabels: string[] = [
|
||||||
|
"ОЦЕНКА",
|
||||||
|
"ПРОДАЖИ В ДОМЕ",
|
||||||
|
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
|
||||||
|
"АНАЛИТИКА",
|
||||||
|
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const overlayTitles: string[] = [
|
||||||
|
"",
|
||||||
|
"ПРОДАЖИ В ДОМЕ",
|
||||||
|
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
|
||||||
|
"АНАЛИТИКА ДОМА",
|
||||||
|
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const overlayNums: string[] = ["", "04", "05", "06", "07"];
|
||||||
|
|
||||||
|
export const version = "v2.0.6";
|
||||||
|
|
@ -79,7 +79,7 @@ export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0];
|
||||||
*/
|
*/
|
||||||
export const UNCONFIRMED_CITY_LABEL = "Автоопределение";
|
export const UNCONFIRMED_CITY_LABEL = "Автоопределение";
|
||||||
|
|
||||||
/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */
|
/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/ui-config.ts. */
|
||||||
export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label);
|
export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,10 @@ export interface AggregatedEstimate {
|
||||||
analogs: AnalogLot[]; // top 5-10
|
analogs: AnalogLot[]; // top 5-10
|
||||||
actual_deals: AnalogLot[]; // last 12 mo
|
actual_deals: AnalogLot[]; // last 12 mo
|
||||||
expires_at: string; // ISO datetime
|
expires_at: string; // ISO datetime
|
||||||
|
// PR-D1: срок жизни ссылки/строки (оплаченный доступ, backend migration 240),
|
||||||
|
// НЕ путать с expires_at (актуальность расчёта). null = неоплачено (текущий
|
||||||
|
// трафик целиком).
|
||||||
|
retain_until?: string | null; // ISO datetime
|
||||||
// ── Метаданные ──
|
// ── Метаданные ──
|
||||||
target_address: string | null;
|
target_address: string | null;
|
||||||
target_lat: number | null;
|
target_lat: number | null;
|
||||||
|
|
|
||||||
|
|
@ -37,22 +37,50 @@ _RETRY_SLEEP_S: float = 1.0
|
||||||
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
|
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
|
||||||
|
|
||||||
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
|
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
|
||||||
# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает:
|
# Адрес пробы. Требования к нему ровно три, и robots.txt им отвечает:
|
||||||
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
|
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
|
||||||
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
|
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
|
||||||
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
|
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
|
||||||
# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие
|
# 2) та же площадка, что реально отказывает: TLS-рукопожатие и маршрут до ЕЁ edge,
|
||||||
# и маршрут до её edge, а не до нейтрального хоста;
|
# а не до нейтрального хоста;
|
||||||
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который
|
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл, который
|
||||||
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
|
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
|
||||||
# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16
|
#
|
||||||
# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08).
|
# АДРЕС НА КАЖДЫЙ ИСТОЧНИК, а не один зашитый (#2800). До этого проба всех узлов
|
||||||
_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt"
|
# ходила на Авито, и её зелёный ответ читался как «узел годен», хотя означал
|
||||||
# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу
|
# «годен для Авито». Замер на проде 09.08.2026 показал ровно ту цену: узел id=1
|
||||||
# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок
|
# отдавал 200 на Авито/Яндексе и 500 NS_ERROR_PROXY_BAD_GATEWAY на Домклике, имея
|
||||||
# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот
|
# при этом browser_fail_streak=0 и свежую пробу.
|
||||||
# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые
|
#
|
||||||
# развёртки его не используют.
|
# ХОСТ — РАБОЧИЙ, А НЕ APEX-ДОМЕН. Тот же замер: через узел id=1
|
||||||
|
# `domclick.ru/robots.txt` отдаёт 200, а `bff-search-web.domclick.ru/robots.txt` —
|
||||||
|
# 500. Боевой сбор Домклика ходит именно на bff (providers/domclick/serp.py::
|
||||||
|
# _BFF_BASE), поэтому проба по apex была бы зелёной и бесполезной — та же ошибка
|
||||||
|
# «проба идёт не рабочим путём», что и #2723, на третьей оси.
|
||||||
|
_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",
|
||||||
|
}
|
||||||
|
_PROXY_PROBE_URL: str = _PROBE_URLS["avito"]
|
||||||
|
# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool
|
||||||
|
# перебирает его, чтобы не заводить второй список площадок на стороне backend'а.
|
||||||
|
PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS)
|
||||||
|
|
||||||
|
# HTTP 200 + непустой HTML ещё не значит «площадка ответила»: замер 09.08 — Циан
|
||||||
|
# через узел id=1 отдаёт 200 и 374 КБ HTML со страницей «Ошибка — Циан» вместо
|
||||||
|
# robots.txt. Такой ответ проба до #2800 засчитывала как успех. Маркер ниже есть в
|
||||||
|
# robots.txt всех четырёх рабочих хостов (проверено вживую) и отсутствует в
|
||||||
|
# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали
|
||||||
|
# отказ с кодом 200».
|
||||||
|
_PROBE_CONTENT_MARKER: str = "User-agent"
|
||||||
|
# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес:
|
||||||
|
# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с
|
||||||
|
# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠
|
||||||
|
# прокси сессии) — ровно тот relaunch-шторм, который лечил sticky-lease фикс.
|
||||||
|
# 'generic' — свой инстанс, боевые развёртки его не используют. Аргумент `source` у
|
||||||
|
# probe_proxy_via_browser выбирает АДРЕС (какую площадку спрашиваем), а не инстанс.
|
||||||
_PROXY_PROBE_SOURCE: str = "generic"
|
_PROXY_PROBE_SOURCE: str = "generic"
|
||||||
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
|
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
|
||||||
# замерено на проде вместе с релончем, плюс запас на медленный узел.
|
# замерено на проде вместе с релончем, плюс запас на медленный узел.
|
||||||
|
|
@ -126,8 +154,10 @@ def classify_browser_probe(status: int | None, detail: str) -> str:
|
||||||
browser_fail_streak.
|
browser_fail_streak.
|
||||||
- "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут,
|
- "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут,
|
||||||
503 «no proxy configured», прочие 5xx). Узел не виноват.
|
503 «no proxy configured», прочие 5xx). Узел не виноват.
|
||||||
- "page" — тракт сработал, но ответ не похож на страницу (пустое тело).
|
- "page" — тракт сработал, но ответ не похож на запрошенный ресурс (пустое
|
||||||
Узел не виноват; повод посмотреть на площадку, не на пул.
|
тело либо 200 со страницей-заглушкой вместо robots.txt, #2800).
|
||||||
|
Браузерный тракт узла исправен — не годится ПАРА «узел ×
|
||||||
|
площадка»: этому exit-IP площадка ресурс не отдала.
|
||||||
"""
|
"""
|
||||||
if status is None:
|
if status is None:
|
||||||
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
|
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
|
||||||
|
|
@ -143,10 +173,11 @@ async def probe_proxy_via_browser(
|
||||||
proxy_url: str,
|
proxy_url: str,
|
||||||
*,
|
*,
|
||||||
proxy_kind: str = "http",
|
proxy_kind: str = "http",
|
||||||
url: str = _PROXY_PROBE_URL,
|
source: str = "avito",
|
||||||
|
url: str | None = None,
|
||||||
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
|
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
|
||||||
) -> tuple[bool, str | None, str]:
|
) -> tuple[bool, str | None, str]:
|
||||||
"""Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси.
|
"""Проверить ПАРУ «узел × площадка» тем же трактом, что и работа: сайдкар → camoufox → прокси.
|
||||||
|
|
||||||
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
|
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
|
||||||
вредна — health-checker проверяет узлы, в том числе арендованные, и не должен
|
вредна — health-checker проверяет узлы, в том числе арендованные, и не должен
|
||||||
|
|
@ -156,13 +187,18 @@ async def probe_proxy_via_browser(
|
||||||
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная
|
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная
|
||||||
нагрузка на неё, ради которой проба и затевалась бы наоборот.
|
нагрузка на неё, ради которой проба и затевалась бы наоборот.
|
||||||
|
|
||||||
|
`source` выбирает АДРЕС пробы (`_PROBE_URLS`, #2800) — рабочий хост именно этой
|
||||||
|
площадки. Прежняя сигнатура спрашивала только Авито, и её зелёный ответ означал
|
||||||
|
«узел годен для Авито», а читался как «узел годен». `url` (явный адрес) остаётся
|
||||||
|
для тестов и разовых проверок и перекрывает `source`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind —
|
(ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind —
|
||||||
"proxy" / "sidecar" / "page" (см. classify_browser_probe), detail —
|
"proxy" / "sidecar" / "page" (см. classify_browser_probe), detail —
|
||||||
обрезанный текст для лога.
|
обрезанный текст для лога.
|
||||||
"""
|
"""
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"url": url,
|
"url": url or _PROBE_URLS.get(source, _PROXY_PROBE_URL),
|
||||||
"source": _PROXY_PROBE_SOURCE,
|
"source": _PROXY_PROBE_SOURCE,
|
||||||
"proxy": proxy_url,
|
"proxy": proxy_url,
|
||||||
"proxy_kind": proxy_kind,
|
"proxy_kind": proxy_kind,
|
||||||
|
|
@ -184,6 +220,15 @@ async def probe_proxy_via_browser(
|
||||||
html = ""
|
html = ""
|
||||||
if not html:
|
if not html:
|
||||||
return False, classify_browser_probe(resp.status_code, detail), "empty 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 КБ «Ошибка — Циан»).
|
||||||
|
# Тракт узла исправен, негодна пара — отсюда "page", а не "proxy".
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"page",
|
||||||
|
f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}",
|
||||||
|
)
|
||||||
return True, None, f"html_len={len(html)}"
|
return True, None, f"html_len={len(html)}"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3742,6 +3742,7 @@ async def run_domclick_city_sweep(
|
||||||
config: ScraperConfig,
|
config: ScraperConfig,
|
||||||
matcher: HouseMatcher,
|
matcher: HouseMatcher,
|
||||||
shutdown_requested: Callable[[], bool] = lambda: False,
|
shutdown_requested: Callable[[], bool] = lambda: False,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
|
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
|
||||||
rooms: list[int] | None = None,
|
rooms: list[int] | None = None,
|
||||||
pages: int = 100,
|
pages: int = 100,
|
||||||
|
|
@ -3753,11 +3754,16 @@ async def run_domclick_city_sweep(
|
||||||
Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
|
Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
|
||||||
DomClick не поддерживает geo-radius (fetch_around → NotImplementedError),
|
DomClick не поддерживает geo-radius (fetch_around → NotImplementedError),
|
||||||
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все
|
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все
|
||||||
ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → shared mobile proxy.
|
ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → прокси-пул.
|
||||||
|
|
||||||
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
|
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
|
||||||
прямых импортов app.* (см. scraper_kit.contracts).
|
прямых импортов app.* (см. scraper_kit.contracts).
|
||||||
|
|
||||||
|
proxy_provider (#2657): домклик был единственным sweep-источником без пула — ходил
|
||||||
|
через статический SCRAPER_PROXY_URL сайдкара, а тот адрес с 06.08 отдаёт
|
||||||
|
NS_ERROR_PROXY_BAD_GATEWAY именно на домкликовский BFF. Теперь узел берётся из пула,
|
||||||
|
как у avito/cian/yandex, и плохой узел ротируется/банится штатными механизмами.
|
||||||
|
|
||||||
ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned
|
ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned
|
||||||
(независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты,
|
(независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты,
|
||||||
прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed.
|
прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed.
|
||||||
|
|
@ -3807,7 +3813,7 @@ async def run_domclick_city_sweep(
|
||||||
async def _domclick_phase() -> None:
|
async def _domclick_phase() -> None:
|
||||||
"""Единственная citywide-фаза: fetch_city + save."""
|
"""Единственная citywide-фаза: fetch_city + save."""
|
||||||
nonlocal lots
|
nonlocal lots
|
||||||
async with DomClickScraper(config) as _scraper:
|
async with DomClickScraper(config, proxy_provider=proxy_provider) as _scraper:
|
||||||
_scraper_ref.append(_scraper)
|
_scraper_ref.append(_scraper)
|
||||||
if request_delay_sec is not None:
|
if request_delay_sec is not None:
|
||||||
_scraper.request_delay_sec = _resolved_delay
|
_scraper.request_delay_sec = _resolved_delay
|
||||||
|
|
|
||||||
|
|
@ -666,6 +666,7 @@ async def _job_domclick_city_sweep(
|
||||||
config=ctx.config,
|
config=ctx.config,
|
||||||
matcher=ctx.matcher,
|
matcher=ctx.matcher,
|
||||||
shutdown_requested=ctx.shutdown_requested,
|
shutdown_requested=ctx.shutdown_requested,
|
||||||
|
proxy_provider=ctx.proxy_provider,
|
||||||
city_id=int(params.get("city_id", 4)),
|
city_id=int(params.get("city_id", 4)),
|
||||||
rooms=params.get("rooms"),
|
rooms=params.get("rooms"),
|
||||||
pages=int(params.get("pages_per_anchor", 5)),
|
pages=int(params.get("pages_per_anchor", 5)),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""Cian.ru newbuilding (ЖК) catalog page scraper.
|
"""Cian.ru newbuilding (ЖК) catalog page scraper.
|
||||||
|
|
||||||
URL: https://zhk-<slug>-<city>-i.cian.ru/
|
URL: https://zhk-<slug>-<city>-i.cian.ru/
|
||||||
MFE: 'newbuilding-card-desktop-frontend', key: 'initialState'
|
MFE: 'newbuilding-card-desktop-frontend' ИЛИ 'newbuilding-card-desktop-fichering-frontend'
|
||||||
|
(Циан раскатывает новый фронт на часть ЖК — оба имени живые, см. _NEWBUILDING_MFES),
|
||||||
|
key: 'initialState'
|
||||||
|
|
||||||
Sister state containers extracted from same MFE initialState top-level keys:
|
Sister state containers extracted from same MFE initialState top-level keys:
|
||||||
- realtyValuation: 7-month price chart (data.priceDynamics.chart.data.{labels,values})
|
- realtyValuation: 7-month price chart (data.priceDynamics.chart.data.{labels,values})
|
||||||
|
|
@ -24,12 +26,12 @@ from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
||||||
from scraper_kit.providers._base import build_curl_cffi_session
|
from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
||||||
|
|
||||||
from scraper_kit.contracts import ScraperConfig
|
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -72,32 +74,91 @@ _CATPH_NEWBUILDING_SERP = (
|
||||||
|
|
||||||
# Подписи защитных страниц — подстроки, которых на нормальной ЖК-карточке нет (#2767).
|
# Подписи защитных страниц — подстроки, которых на нормальной ЖК-карточке нет (#2767).
|
||||||
# Список НАМЕРЕННО короткий: он не выносит диагноз, а называет найденное.
|
# Список НАМЕРЕННО короткий: он не выносит диагноз, а называет найденное.
|
||||||
|
#
|
||||||
|
# `cian_waf_block` добавлен по живой пробе 2026-08-09 (#2767, вторая итерация). Список
|
||||||
|
# без него ОТВЕЧАЛ «antibot_markers=none» на странице блокировки — восемь суток подряд,
|
||||||
|
# и ровно этот ответ увёл разбор в гипотезу «Циан сменил разметку». Страница блокировки
|
||||||
|
# Циана НЕ содержит ни одного прежнего маркера: заголовок «Ошибка - Циан», текст
|
||||||
|
# «Обнаружен подозрительный трафик», и единственная машинная подпись — код страницы
|
||||||
|
# `cian_waf_block` в mailto-теле для поддержки. Весит она 374 168 байт (инлайн-аналитика),
|
||||||
|
# то есть по размеру НЕОТЛИЧИМА от целой карточки — «~1 МБ значит разметка» не работает,
|
||||||
|
# различает только маркер. Молчание списка читалось как «защита проверена и не нашлась»;
|
||||||
|
# на деле список просто не знал этой подписи (см. capture в #2767).
|
||||||
|
# Голой подстроки "captcha" здесь НЕТ намеренно (живая проба 2026-08-09): ЛЮБАЯ здоровая
|
||||||
|
# карточка ЖК грузит скрипт SmartCaptcha, поэтому "captcha" на ней есть всегда. Стена
|
||||||
|
# капчи опознаётся по своему заголовку «Вы не робот?» — он в списке ниже и на проде
|
||||||
|
# срабатывал вместе с "captcha" на всех 15-килобайтных стенах, так что снятие ложного
|
||||||
|
# маркера ничего не теряет. Пока он тут был, отказ разбора большой (682-886 КБ) страницы
|
||||||
|
# сопровождался баном ЗДОРОВОГО узла с причиной "captcha".
|
||||||
_ANTIBOT_MARKERS = (
|
_ANTIBOT_MARKERS = (
|
||||||
"captcha", # покрывает и recaptcha, и smartcaptcha
|
|
||||||
"qrator",
|
"qrator",
|
||||||
"ddos-guard",
|
"ddos-guard",
|
||||||
"cf-chl", # Cloudflare challenge
|
"cf-chl", # Cloudflare challenge
|
||||||
"доступ ограничен",
|
"доступ ограничен",
|
||||||
"вы не робот",
|
"вы не робот",
|
||||||
|
"cian_waf_block", # WAF-блок Циана, проверено живьём 2026-08-09
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _describe_parse_miss(html: str) -> str:
|
def _describe_parse_miss(html: str) -> str:
|
||||||
"""Факты о полученной странице для строки отказа разбора: размер + найденные маркеры.
|
"""Факты о полученной странице для строки отказа разбора: размер + найденные маркеры.
|
||||||
|
|
||||||
НЕ диагноз (#2767): перечисляет найденное, причину не называет. Размер разделяет
|
НЕ диагноз (#2767): перечисляет найденное, причину не называет.
|
||||||
две гипотезы одним числом — здоровая ЖК-карточка весит 1.17 МБ и содержит
|
|
||||||
initialState (verified live 2026-06-15), поэтому ~1 МБ без состояния читается как
|
Размер САМ ПО СЕБЕ гипотезы не разделяет — это выяснилось живой пробой 2026-08-09.
|
||||||
«страница пришла целиком, изменилась разметка», а десятки килобайт — как защитная
|
Прежняя трактовка («~1 МБ = страница целиком, значит разметка; десятки килобайт =
|
||||||
заглушка (страница без состояния = подпись антибота, #972). До этой строки в логе
|
заглушка») верна только для капчи: WAF-страница Циана весит 374 КБ и от целой
|
||||||
не было ни байтов, ни признаков защиты: восемь суток по 25 предупреждений за
|
карточки (542-557 КБ) по размеру неотличима. Разделяет ИМЕННО маркер, поэтому
|
||||||
прогон не отвечали на единственный вопрос, ради которого их читают.
|
полнота `_ANTIBOT_MARKERS` здесь несущая, а не украшение.
|
||||||
"""
|
"""
|
||||||
lowered = html.lower()
|
lowered = html.lower()
|
||||||
found = [m for m in _ANTIBOT_MARKERS if m in lowered]
|
found = [m for m in _ANTIBOT_MARKERS if m in lowered]
|
||||||
return f"html_len={len(html)} antibot_markers={','.join(found) if found else 'none'}"
|
return f"html_len={len(html)} antibot_markers={','.join(found) if found else 'none'}"
|
||||||
|
|
||||||
|
|
||||||
|
# MFE, под которым Циан кладёт состояние ЖК-карточки. Имён ДВА, и оба живые — замер
|
||||||
|
# 2026-08-09, один и тот же прогон:
|
||||||
|
# zhk-pihtovyy-ekb-i.cian.ru → newbuilding-card-desktop-frontend
|
||||||
|
# zhk-kosmos-ekb-i.cian.ru → newbuilding-card-desktop-fichering-frontend
|
||||||
|
# zhk-les-ekb-i.cian.ru → newbuilding-card-desktop-fichering-frontend
|
||||||
|
# Второе имя (`fichering`) — раскатка нового фронта Циана на ЧАСТЬ ЖК. Страницы под ним
|
||||||
|
# приходят целиком (810-993 КБ, ни капчи, ни WAF), состояние в них лежит под тем же
|
||||||
|
# ключом `initialState` и той же формы: `newbuilding.id`/`name` на месте,
|
||||||
|
# `realtyValuation.data.priceDynamics` — тоже. Именно из-за него `houses_price_dynamics`
|
||||||
|
# стояла с 2026-07-26: у ЖК, переехавших на новый фронт, график есть, но старое имя MFE
|
||||||
|
# его не находило, а отказ выглядел как «страница целая, разметка изменилась».
|
||||||
|
# Порядок — исторический первым: большинство ЖК всё ещё на нём, лишнего разбора не будет.
|
||||||
|
_NEWBUILDING_MFES = (
|
||||||
|
"newbuilding-card-desktop-frontend",
|
||||||
|
"newbuilding-card-desktop-fichering-frontend",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_nb_state(html: str) -> tuple[dict[str, Any] | None, str]:
|
||||||
|
"""Состояние ЖК-карточки + имя MFE, под которым оно нашлось.
|
||||||
|
|
||||||
|
Имя возвращается, потому что sister-контейнеры читаются из `extract_all_states()`
|
||||||
|
по ТОМУ ЖЕ MFE — искать их под чужим именем бессмысленно.
|
||||||
|
"""
|
||||||
|
for mfe in _NEWBUILDING_MFES:
|
||||||
|
state = extract_state(html, mfe=mfe, key="initialState")
|
||||||
|
if state is not None:
|
||||||
|
return state, mfe
|
||||||
|
return None, _NEWBUILDING_MFES[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_by(html: str) -> str | None:
|
||||||
|
"""Найденные подписи защиты, если страница — блок; иначе None.
|
||||||
|
|
||||||
|
Отдельно от `_describe_parse_miss` потому, что у результата РАЗНЫЕ потребители:
|
||||||
|
там строка для человека в журнале, здесь — сигнал в пул прокси (`report_ban`),
|
||||||
|
после которого `acquire('cian')` перестаёт выдавать сожжённый узел.
|
||||||
|
"""
|
||||||
|
lowered = html.lower()
|
||||||
|
found = [m for m in _ANTIBOT_MARKERS if m in lowered]
|
||||||
|
return ",".join(found) if found else None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NewbuildingEnrichment:
|
class NewbuildingEnrichment:
|
||||||
"""Result of fetch_newbuilding() — houses row + sister containers."""
|
"""Result of fetch_newbuilding() — houses row + sister containers."""
|
||||||
|
|
@ -153,12 +214,12 @@ async def fetch_newbuilding(
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig | None = None,
|
||||||
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
|
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> NewbuildingEnrichment | None:
|
) -> NewbuildingEnrichment | None:
|
||||||
"""Fetch ЖК catalog page и extract все containers.
|
"""Fetch ЖК catalog page и extract все containers.
|
||||||
|
|
||||||
HTML страница ЖК теперь тянется через BrowserFetcher (camoufox), потому что
|
HTML страница ЖК тянется через BrowserFetcher (camoufox), потому что curl_cffi
|
||||||
curl_cffi получает страницу без initialState под анти-ботом Cian (#972).
|
получает страницу без initialState под анти-ботом Cian (#972).
|
||||||
tradein-browser верифицирован живым: 1.17 MB, initialState присутствует.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
|
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
|
||||||
|
|
@ -168,31 +229,64 @@ async def fetch_newbuilding(
|
||||||
session: зарезервирован для обратной совместимости с вызывающими;
|
session: зарезервирован для обратной совместимости с вызывающими;
|
||||||
больше не используется для fetch страницы ЖК. Resolve-функции
|
больше не используется для fetch страницы ЖК. Resolve-функции
|
||||||
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
|
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
|
||||||
|
proxy_provider: пул браузерных прокси (#2767). None → узел берётся из env
|
||||||
|
сайдкара, как было; см. блок ниже — именно этот пропуск и стоил 8 суток.
|
||||||
|
|
||||||
Returns: NewbuildingEnrichment, or None если fetch / parse failed.
|
Returns: NewbuildingEnrichment, or None если fetch / parse failed.
|
||||||
"""
|
"""
|
||||||
# #2362 (F4b): НЕ переведено на _base.build_browser_fetcher() намеренно.
|
# #2160 P4 wiring gap, закрыт по живой пробе 2026-08-09 (#2767).
|
||||||
# build_browser_fetcher() требует ScraperConfig mandatory (без default) — это
|
#
|
||||||
# весь смысл фабрики (footgun-класс #2322/#2330). Но `config` здесь ЗАКОННО
|
# До этой правки здесь конструировался BrowserFetcher(source="cian", endpoint=...)
|
||||||
# Optional: admin.py debug-роут и test_scraper_kit_newbuilding_endpoint.py
|
# БЕЗ proxy_provider — единственный cian-путь мимо пула (serp.py всегда шёл через
|
||||||
# (test_cian_fetch_newbuilding_endpoint_none_without_config) вызывают
|
# build_browser_fetcher(..., proxy_provider=...)). Следствие: обогащение всегда
|
||||||
# fetch_newbuilding() БЕЗ config= и ожидают graceful endpoint=None (не крash).
|
# выходило через ОДИН env-узел сайдкара (SCRAPER_PROXY_URL = узел пула #1,
|
||||||
# Подмена на build_browser_fetcher(config, ...) уронила бы AttributeError на
|
# asocks-residential-1). Циан забанил его exit-IP, и дальше не помогало ничто:
|
||||||
# config=None вместо текущего degrade-to-None — реальная регрессия поведения,
|
# `report_ban` без lease — no-op, ротация без пула невозможна, 25 попыток за ночь
|
||||||
# а не чистый call-site swap. Оставлено как есть; см. PR #2362 caveat notes.
|
# уходили в один и тот же сожжённый адрес.
|
||||||
endpoint = config.browser_http_endpoint if config is not None else None
|
#
|
||||||
async with BrowserFetcher(source="cian", endpoint=endpoint) as browser:
|
# Замер 2026-08-09, одна и та же страница zhk-pihtovyy-ekb-i.cian.ru, один заход:
|
||||||
|
# узел #1 asocks-residential-1 → 374 168 Б, cian_waf_block, initialState нет
|
||||||
|
# узел #9 asocks-mobile-1 → 542 021 Б, initialState ЕСТЬ
|
||||||
|
# узел #10 asocks-mobile-2 → 557 235 Б, initialState ЕСТЬ
|
||||||
|
# узел #11 asocks-mobile-3 → 557 163 Б, initialState ЕСТЬ
|
||||||
|
# Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState'
|
||||||
|
# разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор.
|
||||||
|
#
|
||||||
|
# config=None остаётся законным (admin.py debug-роут и
|
||||||
|
# test_cian_fetch_newbuilding_endpoint_none_without_config зовут без config= и
|
||||||
|
# ждут graceful endpoint=None, а не AttributeError), поэтому фабрика применяется
|
||||||
|
# только когда config есть — она требует ScraperConfig mandatory by design.
|
||||||
|
if config is not None:
|
||||||
|
browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
|
||||||
|
else:
|
||||||
|
browser = BrowserFetcher(source="cian", endpoint=None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть
|
||||||
|
# прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый).
|
||||||
|
# При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP.
|
||||||
|
# Понадобится дешевле — поднимать фетчер в вызывающий цикл и передавать сюда.
|
||||||
|
async with browser:
|
||||||
html = await browser.fetch(zhk_url)
|
html = await browser.fetch(zhk_url)
|
||||||
|
# Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода
|
||||||
|
# из `async with` узел уже отпущен и `report_ban` стал бы no-op.
|
||||||
|
nb_state, mfe = _extract_nb_state(html)
|
||||||
|
# Условие бана — «состояния НЕТ и есть подпись защиты», а не одна подпись.
|
||||||
|
# Живая регрессия 2026-08-09 (поймана на проде через 17 секунд после деплоя):
|
||||||
|
# маркер по одному лишь тексту забанил два ЗДОРОВЫХ узла на странице, которая
|
||||||
|
# РАЗОБРАЛАСЬ, — обычная карточка ЖК грузит скрипт SmartCaptcha, и подстрока
|
||||||
|
# "captcha" на ней есть всегда. Список маркеров создавался для объяснения УЖЕ
|
||||||
|
# СЛУЧИВШЕГОСЯ отказа разбора (`_describe_parse_miss` зовётся только оттуда);
|
||||||
|
# применять его как самостоятельный детектор — менять смысл признака.
|
||||||
|
if nb_state is None:
|
||||||
|
blocked = _blocked_by(html)
|
||||||
|
if blocked is not None:
|
||||||
|
browser.report_ban(f"cian newbuilding: {blocked}")
|
||||||
|
|
||||||
# Cian ЖК-карточка: MFE 'newbuilding-card-desktop-frontend', key 'initialState'
|
|
||||||
# (verified live 2026-06-15).
|
|
||||||
mfe = "newbuilding-card-desktop-frontend"
|
|
||||||
|
|
||||||
nb_state = extract_state(html, mfe=mfe, key="initialState")
|
|
||||||
if nb_state is None:
|
if nb_state is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Cian newbuilding %s: initialState extraction failed — %s",
|
"Cian newbuilding %s: initialState extraction failed (искали в MFE %s) — %s",
|
||||||
zhk_url,
|
zhk_url,
|
||||||
|
"/".join(_NEWBUILDING_MFES),
|
||||||
_describe_parse_miss(html),
|
_describe_parse_miss(html),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ Strangler-копия (#2133) боевого `app.services.scrapers.domclick`, р
|
||||||
(golden-parity — tests/test_scraper_kit_domclick_golden_parity.py).
|
(golden-parity — tests/test_scraper_kit_domclick_golden_parity.py).
|
||||||
|
|
||||||
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
|
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
|
||||||
BrowserFetcher(source="domclick") (generic provider → shared mobile proxy).
|
BrowserFetcher(source="domclick") → узел прокси-пула (#2657; до этого — статический
|
||||||
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.
|
SCRAPER_PROXY_URL сайдкара, см. fetch_city). QRATOR банит прямые datacenter-запросы,
|
||||||
|
но пропускает через mobile proxy — проверено вживую 09.08.2026.
|
||||||
|
|
||||||
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
|
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
|
||||||
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
|
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
|
||||||
|
|
@ -41,7 +42,7 @@ from scraper_kit.pricing import BisectionConfig, ProbeResult, walk_price_range
|
||||||
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
|
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from scraper_kit.contracts import ScraperConfig
|
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -251,12 +252,17 @@ class DomClickScraper(BaseScraper):
|
||||||
config: ScraperConfig,
|
config: ScraperConfig,
|
||||||
*,
|
*,
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
|
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
|
||||||
# BrowserFetcher, delay_provider заменяет прямой импорт
|
# BrowserFetcher, delay_provider заменяет прямой импорт
|
||||||
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
|
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
|
||||||
self._config = config
|
self._config = config
|
||||||
|
# #2657: proxy_provider — как у cian/yandex/avito. До этого домклик был
|
||||||
|
# ЕДИНСТВЕННЫМ sweep-источником без пула (#2160 P4 wiring gap) и потому ходил
|
||||||
|
# через статический SCRAPER_PROXY_URL сайдкара.
|
||||||
|
self._proxy_provider = proxy_provider
|
||||||
if delay_provider is not None:
|
if delay_provider is not None:
|
||||||
self.request_delay_sec = delay_provider(self.name)
|
self.request_delay_sec = delay_provider(self.name)
|
||||||
self.parse_failures: int = 0
|
self.parse_failures: int = 0
|
||||||
|
|
@ -317,15 +323,16 @@ class DomClickScraper(BaseScraper):
|
||||||
out_lots: list[ScrapedLot] = []
|
out_lots: list[ScrapedLot] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
# build_browser_fetcher(config, source) без proxy_provider (дефолт None) —
|
# #2657: proxy_provider прокинут — до этого домклик единственный из sweep-
|
||||||
# поведенчески идентично прежнему прямому BrowserFetcher(source=..., endpoint=...):
|
# источников ходил без пула (#2160 P4 wiring gap), т.е. через статический
|
||||||
# use_pool теперь читает config.use_proxy_pool_browser (#2364 caveat, тот же
|
# SCRAPER_PROXY_URL сайдкара. Прод 06-09.08: этот адрес отдаёт
|
||||||
# класс что и avito) вместо хардкода False, но BrowserFetcher._pool_proxy
|
# NS_ERROR_PROXY_BAD_GATEWAY именно на bff-search-web.domclick.ru (проверено
|
||||||
# вычисляет `use_pool and proxy_provider is not None` — при proxy_provider=None
|
# вживую 09.08: через него 500, через мобильный узел пула — 200 и
|
||||||
# результат всегда False независимо от значения флага (см. _base.py
|
# snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
|
||||||
# build_browser_fetcher docstring: domclick пока не подключён к browser-proxy-
|
# proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
|
||||||
# пулу, #2160 P4 wiring gap). Поведение НЕ меняется.
|
async with build_browser_fetcher(
|
||||||
async with build_browser_fetcher(self._config, "domclick") as fetcher:
|
self._config, "domclick", proxy_provider=self._proxy_provider
|
||||||
|
) as fetcher:
|
||||||
for bucket in ROOM_BUCKETS:
|
for bucket in ROOM_BUCKETS:
|
||||||
logger.info(
|
logger.info(
|
||||||
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
|
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
|
||||||
|
|
@ -350,15 +357,22 @@ class DomClickScraper(BaseScraper):
|
||||||
bucket,
|
bucket,
|
||||||
)
|
)
|
||||||
# #2600 п.1: fetcher (lease) ещё жив — `async with` вокруг этого
|
# #2600 п.1: fetcher (lease) ещё жив — `async with` вокруг этого
|
||||||
# цикла не закрылся, мы внутри его тела. no-op сегодня (домклик
|
# цикла не закрылся, мы внутри его тела. С #2657 (proxy_provider
|
||||||
# SERP собирается БЕЗ proxy_provider, #2160 P4 wiring gap — см.
|
# прокинут выше) это уже не no-op: узел уходит в
|
||||||
# build_browser_fetcher выше), но провода готовы на будущее.
|
# scrape_proxy_source_bans и следующий acquire("domclick") его не
|
||||||
|
# выдаст.
|
||||||
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
|
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
|
||||||
break
|
break
|
||||||
except (ValueError, TypeError) as exc:
|
except Exception as exc:
|
||||||
# Defensive: bucket-level ошибка не должна убивать весь sweep.
|
# Defensive: bucket-level ошибка не должна убивать весь sweep.
|
||||||
# _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
|
# _count/_paginate глотают ошибки РАЗБОРА per-fetch (fetch_errors++),
|
||||||
# но если что-то всё же всплыло — переходим к следующему бакету.
|
# но транспортные (httpx.HTTPStatusError от сайдкара, таймаут узла)
|
||||||
|
# летели мимо и убивали прогон на ПЕРВОМ же бакете — прод 06-09.08:
|
||||||
|
# buckets=0/6, lots=0, четыре дня подряд. Это же лишало смысла
|
||||||
|
# ротацию lease после N подряд провалов (browser_fetcher
|
||||||
|
# _LEASE_ROTATE_AFTER_FAILS): до неё просто не доживали. Ловим
|
||||||
|
# Exception, а не BaseException — CancelledError (SIGTERM-drain,
|
||||||
|
# watchdog asyncio.wait_for) обязан пройти насквозь.
|
||||||
self.fetch_errors += 1
|
self.fetch_errors += 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue