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:
bot-backend 2026-08-10 01:27:38 +05:00
commit 9e5bcd3fa9
82 changed files with 4477 additions and 1265 deletions

View file

@ -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`

View file

@ -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:

View file

@ -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.

View file

@ -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.

View file

@ -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).

View file

@ -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 «Доступ заблокирован», БЕЗ

View file

@ -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)]

View file

@ -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=[

View file

@ -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

View file

@ -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=[

View 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]))

View file

@ -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:

View file

@ -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"

View file

@ -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>&nbsp;<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

View file

@ -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}"
)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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-аутентификации.

View file

@ -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). */}

View file

@ -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;

View file

@ -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

View file

@ -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"

View file

@ -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,
});
});
});

View file

@ -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.
* *

View file

@ -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] });

View 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())

View file

@ -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}")

View file

@ -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,

View file

@ -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 в таске) —

View file

@ -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

View file

@ -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>

View file

@ -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,
), ),
) )

View file

@ -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;
# - одна проба 918 с (замерено) → такт с крестом ~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,17 +974,25 @@ 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). Узел продолжит выдаваться этому "
"источнику (голодание хуже, чем работа через забаненный узел).", "источнику (голодание хуже, чем работа через забаненный узел).",
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
try: counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
ok, fail_kind, detail = await probe_proxy_via_browser( fail_kinds: list[str] = []
_settings.browser_http_endpoint, url, proxy_kind=kind any_ok = False
last_detail = ""
for source in _probe_sources_for(affinity):
try:
ok, fail_kind, detail = await probe_proxy_via_browser(
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
)
if not ok and fail_kind == "proxy":
# Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает
# флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор
# идёт по уже поднятому браузеру с тем же прокси — секунды, и только
# на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью
# (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания.
ok, fail_kind, detail = await probe_proxy_via_browser(
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
)
except Exception:
logger.warning(
"proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан",
proxy_id,
source,
exc_info=True,
)
continue
counters["pair_checked"] += 1
last_detail = detail
if ok:
any_ok = True
else:
fail_kinds.append(fail_kind or "other")
outcome = mark_source_probe(
db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail
) )
except Exception: if outcome == "banned":
logger.warning( counters["pair_banned"] += 1
"proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан", elif outcome == "cleared":
proxy_id, counters["pair_cleared"] += 1
exc_info=True,
) if counters["pair_checked"] == 0:
return "ignored" return "ignored", counters # крест не состоялся — узел не судим
return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail)
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,
} }

View file

@ -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",

View file

@ -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,

View file

@ -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,
) )

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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=Нет доступа к сайту и/или приложению&amp;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">в&nbsp;службу поддержки</a> или на&nbsp;почту <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>

View file

@ -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:

View file

@ -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-заглушка) констрейнта нет вовсе.

View 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

View file

@ -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(),
) )

View file

@ -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(

View 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()

View file

@ -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

View 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]

View file

@ -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}с) — потолок темпа не работает"
) )

View 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

View file

@ -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

View file

@ -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

View file

@ -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"
)

View file

@ -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()

View file

@ -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()

View file

@ -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:

View file

@ -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()

View file

@ -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",

View file

@ -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;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// География // География
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -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>

View file

@ -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

View file

@ -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() {

View file

@ -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;
} }

View file

@ -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;

View file

@ -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;
} }

View file

@ -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),

View file

@ -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()

View file

@ -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("—");
});
});

View file

@ -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,77,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: "обычно 18180 дн.",
count: "69 аналогов",
variant: "success",
},
{
tier: "По медиане",
days: "~65 дн.",
range: "обычно 18182 дн.",
count: "79 аналогов",
variant: "accent",
},
{
tier: "+5%",
days: "~78 дн.",
range: "обычно 16212 дн.",
count: "61 аналог",
variant: "gold",
},
{
tier: "+10%",
days: "~67 дн.",
range: "обычно 26260 дн.",
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";

View file

@ -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 {

View file

@ -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;
}

View 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";

View file

@ -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);
/** /**

View file

@ -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;

View file

@ -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)}"

View file

@ -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

View file

@ -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)),

View file

@ -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

View file

@ -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",