From 5626d9e720fb4b390ddcaa3c19df886bca480a0c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 28 Jul 2026 15:24:21 +0300 Subject: [PATCH 001/130] =?UTF-8?q?feat(mera/b2c):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=80=D0=B0=D0=BC=D0=BA=D0=B0?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D1=81=D1=80=D0=BE=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=83=20(=D1=8D=D1=82=D0=B0=D0=BF=204=20=D0=B8=D0=B7=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три дефекта, каждый блокировал легальный публичный запуск. 1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором терпимо, для человека с улицы — нет. Проверка согласия поставлена первой строкой расчёта, до геокодирования и до обоих мест записи адреса. Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для заявок (миграция 182): IP клиента, версия политики, дословный снимок текста. Отдельная таблица событий не заводилась: согласие даётся ровно на создание этой строки, и когда строка удаляется по сроку, исчезновение доказательства вместе с данными логично. Enforcement НЕ выводится из пустого created_by — первая версия так и делала и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени пользователя, проверяя ценовую логику. Вместо этого явный флаг, который выставляет единственный боевой вызывающий. B2B-поток не тронут: поле согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт. 2. Срок жизни оценки применялся только как фильтр при чтении — физического удаления не было ни в одной фоновой задаче, данные жили вечно вопреки декларированному сроку. Заведена задача удаления пачками с ограничением на прогон и коммитом после каждой пачки, идемпотентная. В расписании она ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные, и первый прогон должен быть под наблюдением. 3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в телеграме. Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного телефона и без обращения в поддержку неидентифицируем — удалить его данные без дополнительной идентификации нельзя. Отдельно: удаление чистит только копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в кодовой базе, нужен ручной шаг. 4. Соответствие текста согласия на фронте и снимка на бэке держалось на комментарии. Теперь есть тест, который ловит расхождение. Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно (типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и это записано в коде. Тесты: 2775 passed. --- tradein-mvp/backend/app/api/v1/lead.py | 16 +- .../backend/app/api/v1/privacy_admin.py | 79 +++++ tradein-mvp/backend/app/api/v1/trade_in.py | 16 +- tradein-mvp/backend/app/core/config.py | 29 ++ tradein-mvp/backend/app/main.py | 2 + tradein-mvp/backend/app/schemas/trade_in.py | 9 + .../backend/app/services/data_erasure.py | 175 ++++++++++ tradein-mvp/backend/app/services/estimator.py | 137 +++++++- .../backend/app/services/product_handlers.py | 21 ++ .../app/tasks/purge_expired_trade_in_data.py | 164 +++++++++ .../192_trade_in_estimates_consent_proof.sql | 93 +++++ .../sql/193_trade_in_privacy_retention.sql | 93 +++++ .../tests/test_consent_text_frontend_sync.py | 89 +++++ .../backend/tests/test_data_erasure.py | 160 +++++++++ .../tests/test_estimate_consent_gate.py | 319 ++++++++++++++++++ .../tests/test_purge_expired_trade_in_data.py | 232 +++++++++++++ 16 files changed, 1622 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/backend/app/api/v1/privacy_admin.py create mode 100644 tradein-mvp/backend/app/services/data_erasure.py create mode 100644 tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py create mode 100644 tradein-mvp/backend/data/sql/192_trade_in_estimates_consent_proof.sql create mode 100644 tradein-mvp/backend/data/sql/193_trade_in_privacy_retention.sql create mode 100644 tradein-mvp/backend/tests/test_consent_text_frontend_sync.py create mode 100644 tradein-mvp/backend/tests/test_data_erasure.py create mode 100644 tradein-mvp/backend/tests/test_estimate_consent_gate.py create mode 100644 tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index a5f1d106..41ebe082 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -21,6 +21,7 @@ from __future__ import annotations import logging import re +from datetime import UTC, datetime, timedelta from typing import Annotated, Any, Literal from uuid import UUID @@ -30,6 +31,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.api.v1.trade_in import _assert_estimate_access +from app.core.config import settings from app.core.db import get_db logger = logging.getLogger(__name__) @@ -115,17 +117,26 @@ async def create_trade_in_lead( # consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182, # ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет # X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден. + # + # ЭТАП 4 B2C: expires_at (migration 193) — раньше лид хранился бессрочно + # (никакого TTL вообще не было, в отличие от trade_in_estimates.expires_at). + # Считаем на insert-time тем же паттерном, что estimator.py делает для + # trade_in_estimates — retention-период вынесен в settings, не хардкод. + expires_at = datetime.now(tz=UTC) + timedelta(days=settings.trade_in_lead_retention_days) + row = ( db.execute( text( """ INSERT INTO trade_in_leads ( estimate_id, phone, consent, source, user_agent, - client_ip, consent_policy_version, consent_text_snapshot + client_ip, consent_policy_version, consent_text_snapshot, + expires_at ) VALUES ( CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent, - CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot + CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot, + :expires_at ) RETURNING CAST(id AS text), created_at """ @@ -139,6 +150,7 @@ async def create_trade_in_lead( "client_ip": client_ip, "consent_policy_version": _CONSENT_POLICY_VERSION, "consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT, + "expires_at": expires_at, }, ) .mappings() diff --git a/tradein-mvp/backend/app/api/v1/privacy_admin.py b/tradein-mvp/backend/app/api/v1/privacy_admin.py new file mode 100644 index 00000000..9ea0ee9d --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/privacy_admin.py @@ -0,0 +1,79 @@ +"""Admin right-to-erasure endpoint (152-ФЗ) — ЭТАП 4 B2C launch, part C. + +Auth не нужен в этом файле — вся ветка `/api/v1/admin/*` уже гейтится +`rbac_guard` middleware в app/main.py (`_ADMIN_API_RE`, role != admin → 403), +тем же паттерном, что app/api/v1/audit.py. + +Мутационный (DELETE), поэтому осторожно: это НЕ self-service для конечного +пользователя. Оператор поддержки/admin вызывает это ПОСЛЕ того, как убедился +(вне этого API — телефон/estimate-ссылка/переписка), что запрос на удаление +реально пришёл от владельца данных, а не от третьего лица, знающего чей-то +номер телефона. Идентификация анонима — см. app/services/data_erasure.py +module docstring (честно про то, что не всегда разрешимо). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.db import get_db + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class DataErasureRequest(BaseModel): + """Хотя бы одно поле обязательно — см. erase_person_data ValueError guard.""" + + username: str | None = Field(default=None, max_length=200) + estimate_ids: list[UUID] | None = None + phone: str | None = Field(default=None, max_length=32) + tg_chat_id: int | None = None + + +@router.post("/privacy/erase") +async def erase_person_data_endpoint( + payload: DataErasureRequest, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, dict[str, int]]: + """Физически удалить данные человека по одному или нескольким идентификаторам. + + Идентификаторы (хотя бы один): + - username — B2B-пилот: удаляет ВСЕ его оценки (created_by=username, CASCADE + подчищает фото/IMV-оценки), связанные лиды, веб-чат поддержки. + - estimate_ids — конкретные оценки по UUID (анонимный путь: человек прислал + ссылку/PDF со своим estimate_id) + лиды, привязанные к ним. + - phone — лиды с этим номером телефона (независимо от привязки к оценке). + - tg_chat_id — Telegram-поддержка (@MERAsupport_bot), включая переписку В + ЭТОЙ БД. НЕ удаляет зеркало в Telegram-топике (см. + app/services/data_erasure.py — ВАЖНЫЙ ФАКТ, честно, не скрываем). + + 422 если ни один идентификатор не передан (не даём случайно вызвать + "удали всё" пустым телом). + """ + if not any([payload.username, payload.estimate_ids, payload.phone, payload.tg_chat_id]): + raise HTTPException( + status_code=422, + detail="at least one identifier required: username / estimate_ids / phone / tg_chat_id", + ) + + from app.services.data_erasure import erase_person_data + + counters = await asyncio.to_thread( + erase_person_data, + db, + username=payload.username, + estimate_ids=payload.estimate_ids, + phone=payload.phone, + tg_chat_id=payload.tg_chat_id, + ) + logger.info("admin privacy erase requested -> %s", counters) + return {"deleted": counters} diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 13f4213a..4493c542 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -171,8 +171,22 @@ async def estimate( # явный 503 — так любая БУДУЩАЯ реальная ошибка становится видимой, а не # «глотается» шлюзом. HTTPException пробрасываем как есть (это не сбой). # created_by (#656) прокидываем в estimate_quality для скоупа /history. + # ЭТАП 4 B2C (152-ФЗ): require_consent=True только когда нет + # X-Authenticated-User — сегодня rbac_guard (app/core/rbac.py) уже требует + # этот заголовок на любом non-public пути, так что эта ветка пока + # недостижима в проде (анонимный /estimate ещё не открыт другими частями + # ЭТАП 4/B2C работ) — гейт готов ЗАРАНЕЕ, на момент открытия анонимного + # доступа. client_ip — proof-of-consent (estimate_quality персистит его + # на trade_in_estimates только когда require_consent=True; B2B-пилоты + # остаются NULL, см. estimator.py::_estimate_consent_persist_fields). try: - result = await estimate_quality(payload, db, created_by=x_authenticated_user) + result = await estimate_quality( + payload, + db, + created_by=x_authenticated_user, + client_ip=_client_ip(request), + require_consent=x_authenticated_user is None, + ) except HTTPException: raise except Exception: diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 7340a8ee..2b2d264e 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -574,6 +574,35 @@ class Settings(BaseSettings): # допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS. avito_stale_ttl_days: int = 10 + # ── ЭТАП 4 B2C launch — retention / erasure (152-ФЗ) ──────────────────── + # trade_in_estimates.expires_at TTL (часы от момента создания). Раньше был + # хардкод `timedelta(hours=24)` в estimator.py (x2: главный INSERT + + # _empty_estimate fallback) — вынесено в настройку, чтобы retention-период + # не требовал правки кода. 24ч — продуктовое решение MVP (оценка живёт + # "сессию" клиента, не архив); юридически обоснованный срок хранения адреса + # физлица для анонимного B2C — решение не инженера, см. итоговый комментарий + # к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS. + trade_in_estimate_retention_hours: int = 24 + + # trade_in_leads.expires_at TTL (дни от момента создания, migration 193). + # У trade_in_leads раньше вообще не было срока хранения — лид (телефон + + # согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для + # НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон + # 90-180 дней при отсутствии дальнейшего договорного отношения с клиентом); + # если лид конвертировался в реальную сделку/договор — для него должен + # действовать ДРУГОЙ (договорной) срок хранения, но в кодовой базе нет + # механизма отметки "лид конвертирован" — этого разграничения здесь НЕТ, + # см. итоговый комментарий к задаче (конкретный юридически обоснованный + # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. + trade_in_lead_retention_days: int = 180 + + # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним + # DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама + # крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) — + # это ограничивает ОДНУ транзакцию, не общий прогресс. ENV: + # TRADE_IN_PURGE_BATCH_SIZE. + trade_in_purge_batch_size: int = 500 + # ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ───────────────────── # Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России» # (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 65fb5098..07d58fa8 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -28,6 +28,7 @@ from app.api.v1 import ( geocode, lead, me, + privacy_admin, search, support, trade_in, @@ -161,6 +162,7 @@ def health() -> dict[str, str]: app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"]) +app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 69dee79d..7e361bde 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -42,6 +42,15 @@ class TradeInEstimateInput(BaseModel): has_mortgage: bool | None = None # client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167). + # ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 192). + # Enforcement (НЕ здесь): app.services.estimator.estimate_quality проверяет + # `created_by is None and not consent -> 422` ДО первого INSERT адреса в + # trade_in_estimates. Здесь поле намеренно `bool | None = None`, а НЕ + # `Literal[True]` (как TradeInLeadInput.consent) — сделать True строго- + # обязательным на уровне Pydantic сломало бы B2B-пилотов: их согласие + # закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе. + consent: bool | None = None + class AnalogLot(BaseModel): address: str diff --git a/tradein-mvp/backend/app/services/data_erasure.py b/tradein-mvp/backend/app/services/data_erasure.py new file mode 100644 index 00000000..b2053dae --- /dev/null +++ b/tradein-mvp/backend/app/services/data_erasure.py @@ -0,0 +1,175 @@ +"""Right-to-erasure mechanism (152-ФЗ) — ЭТАП 4 B2C launch, part C. + +WHY: + trade_in has no self-service "delete my data" endpoint at all. Both B2B + pilots (identified by `created_by` username) and future anonymous B2C + users need SOME way to have their personal data physically removed on + request, not just after their retention TTL expires + (app/tasks/purge_expired_trade_in_data.py handles the TTL path, this + module handles the on-demand path). + +WHO CAN BE IDENTIFIED, HONESTLY: + - B2B pilot (has a `username`): trivially -- `created_by = username` scopes + every estimate they created; leads/support threads follow from there. + - Anonymous person: has NO username. This function can ONLY act on + identifiers the requester can actually supply: + * `estimate_ids` -- if they still have the link/PDF from their estimate + (the UUID in the URL/QR-code IS their proof of "this is mine"). + * `phone` -- if they left a contact-request lead with that phone. + * `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own + Telegram chat id -- not guessable/spoofable by a third party the way + a name or IP would be). + If an anonymous person has NONE of these (e.g. they only remember the + street address, or ran an estimate but never saved anything and didn't + log support contact) -- THIS IS HONESTLY UNRESOLVABLE without additional + identification. There is no username, no stable session, nothing in the + DB schema today that lets a support operator find "the one estimate this + specific stranger made three days ago" among many. Do not paper over + this: an operator facing that case must say so, not silently pick "the + closest match". + +⚠️ TELEGRAM CAVEAT (152-ФЗ, honestly, do not omit): + Every tg_support_messages row was, at send time, ALSO mirrored by the bot + into the support-group Telegram topic (see app/services/tgbot/bridge.py, + 186_tg_support.sql). Deleting `tg_support_users` here only removes the + copy IN THIS DATABASE. The mirrored copy lives in the Telegram supergroup, + outside this function's reach, and is NOT deleted by anything in this + codebase. A complete erasure across the whole chain requires a SEPARATE + manual step (Telegram Bot API `deleteMessage` per `topic_message_id` in + the supergroup) that is out of scope here. Do not cite this function's + return value as proof of full erasure of the Telegram-side copy. + +WHAT ELSE IS *NOT* TOUCHED (known gap, flagged, not silently dropped): + `user_events` (184_user_events.sql) logs `estimate_request` events with a + JSONB payload that includes `address`/`area_m2`/`rooms` and is keyed by + `username` (empty string for anonymous callers today) + `ip_address`, with + NO FK to trade_in_estimates (decoupled/append-only by explicit design -- + see that migration's comment). This function does NOT purge user_events: + it is an audit/analytics log, not an estimate/lead/support record, and + deciding whether "audit trail" is a legitimate 152-ФЗ retention basis that + overrides an erasure request is a legal call, not an engineering one. Flag + it to whoever handles the request; do not assume it is already covered. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def erase_person_data( + db: Session, + *, + username: str | None = None, + estimate_ids: Sequence[UUID] | None = None, + phone: str | None = None, + tg_chat_id: int | None = None, +) -> dict[str, int]: + """Physically delete a person's data across trade_in tables. + + At least one identifier is required (raises ValueError otherwise -- callers + MUST pass an explicit identifier, never "erase everything" by omission). + + Order of operations matters: leads are captured/deleted BEFORE estimates, + because trade_in_leads.estimate_id is ON DELETE SET NULL (172) -- once the + estimate row is gone, the join key to find "leads that came from this + person's estimate" is gone too. + + Returns per-table deleted-row counters. Callers own committing the ambient + Session lifecycle in whatever way their layer does (this function DOES + commit itself, mirroring app/tasks/*.py conventions, since this is a + one-shot admin operation, not a request-scoped unit of work shared with + other writes). + """ + if not any([username, estimate_ids, phone, tg_chat_id]): + raise ValueError( + "erase_person_data requires at least one identifier: " + "username / estimate_ids / phone / tg_chat_id" + ) + + counters: dict[str, int] = { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 0, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + + # 1. Собрать ПОЛНЫЙ набор estimate_id ДО удаления оценок: явные estimate_ids + # (анонимный путь -- человек прислал ссылку/PDF) + все id с + # created_by=username (B2B-путь). Нужно захватить это СЕЙЧАС -- после + # DELETE FROM trade_in_estimates связанные trade_in_leads.estimate_id + # уйдут в NULL (ON DELETE SET NULL, 172), join станет невозможен. + all_estimate_ids: set[UUID] = set(estimate_ids or []) + if username: + owned = ( + db.execute( + text("SELECT id FROM trade_in_estimates WHERE created_by = :username"), + {"username": username}, + ) + .scalars() + .all() + ) + all_estimate_ids.update(owned) + + # 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по + # телефону (лид мог быть оставлен без attach к оценке вовсе). + ids_param = [str(i) for i in all_estimate_ids] + result = db.execute( + text( + """ + DELETE FROM trade_in_leads + WHERE estimate_id = ANY(CAST(:ids AS uuid[])) + OR phone = :phone + """ + ), + {"ids": ids_param, "phone": phone}, + ) + counters["trade_in_leads_deleted"] = result.rowcount or 0 + + # 3. Оценки (CASCADE подчищает estimate_photos + avito_imv_evaluations). + if all_estimate_ids: + result = db.execute( + text("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": ids_param}, + ) + counters["trade_in_estimates_deleted"] = result.rowcount or 0 + + # 4. Веб-чат поддержки -- ключ username (сайт закрыт Caddy basic_auth, у + # анонима username нет и быть не может, см. 187_web_support_chat.sql). + if username: + result = db.execute( + text("DELETE FROM web_support_threads WHERE username = :username"), + {"username": username}, + ) + counters["web_support_deleted"] = result.rowcount or 0 + + # 5. Telegram-поддержка -- ключ chat_id, ЕДИНСТВЕННЫЙ путь, реально + # доступный анониму без username (см. module docstring). ⚠️ Чистит + # ТОЛЬКО эту БД -- Telegram-топик со своей копией переписки НЕ + # затрагивается, см. ВАЖНЫЙ ФАКТ в docstring выше. + if tg_chat_id is not None: + result = db.execute( + text("DELETE FROM tg_support_users WHERE chat_id = CAST(:chat_id AS bigint)"), + {"chat_id": tg_chat_id}, + ) + counters["tg_support_deleted"] = result.rowcount or 0 + + db.commit() + logger.info( + "erase_person_data: username=%r estimate_ids=%d phone=%s tg_chat_id=%s -> %s", + username, + len(all_estimate_ids), + "" if phone else None, + tg_chat_id, + counters, + ) + return counters + + +__all__: list[str] = ["erase_person_data"] diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d533bb6e..07613f48 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -33,6 +33,7 @@ from datetime import UTC, date, datetime, timedelta from typing import Any, Literal from uuid import uuid4 +from fastapi import HTTPException from scraper_kit.providers.avito.imv import ( IMVAddressNotFoundError, IMVAuthError, @@ -92,6 +93,69 @@ MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней DEALS_PERIOD_MONTHS = 12 # сделки за последний год +# ── ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ) ──────────────── +# Отдельная пара от _CONSENT_POLICY_VERSION/_CONSENT_TEXT_SNAPSHOT в +# app/api/v1/lead.py: там согласие на КОНТАКТ-заявку (обработка телефона для +# CRM), здесь — согласие на САМУ ОЦЕНКУ (обработка адреса + параметров квартиры +# для расчёта стоимости). Разный предмет обработки -> разный текст/версия, даже +# если оба сейчас датированы одним месяцем. +# +# Enforcement: `require_consent` — явный keyword-only флаг у estimate_quality(), +# НЕ вывод из created_by is None. Причина: estimate_quality() — единственная +# ФУНКЦИЯ, но с ДЕСЯТКАМИ прямых вызовов из тестов эстиматора (test_same_ +# building_anchor.py, test_estimator_quarter_index.py и т.д.), почти все зовут +# её как estimate_quality(payload, db) без created_by вообще — они тестируют +# ценовую логику, не auth. Если бы gate триггерился от created_by is None, +# ЛЮБОЙ такой тест внезапно стал бы "анонимным" и падал на 422 (проверено: +# сломал 92 теста при первой попытке этого PR). require_consent=False по +# умолчанию -> НИ ОДИН существующий вызов не меняет поведение. Единственный +# реальный (production) вызывающий — app/api/v1/trade_in.py::estimate() — +# явно передаёт require_consent=(x_authenticated_user is None), т.е. gate +# реально применяется РОВНО там и тогда, где решение принимает rbac (нет +# X-Authenticated-User => анонимный запрос). +# +# СИНХРОННОСТЬ С ФРОНТОМ: сегодня анонимный флоу ещё не открыт (rbac_guard +# требует X-Authenticated-User на ЛЮБОМ non-public path — см. app/core/rbac.py), +# поэтому у этого текста пока НЕТ живого фронтового чекбокса для сверки (в +# отличие от _CONSENT_TEXT_SNAPSHOT в lead.py, см. +# tests/test_consent_text_frontend_sync.py). Когда анонимный /estimate откроется +# и фронт получит свой чекбокс — добавь сюда симметричный sync-тест ПРЕЖДЕ, чем +# полагаться на комментарий (ровно та ошибка, которую эта задача чинит для лидов). +_ESTIMATE_CONSENT_POLICY_VERSION = "2026-07" +_ESTIMATE_CONSENT_TEXT_SNAPSHOT = ( + "Согласен(-на) на обработку персональных данных (адрес объекта и параметры " + "квартиры) в целях предварительной оценки стоимости в соответствии с " + "Федеральным законом «О персональных данных» № 152-ФЗ" +) + + +def _estimate_consent_persist_fields( + require_consent: bool, client_ip: str | None +) -> dict[str, Any]: + """Поля consent/client_ip/policy/snapshot для INSERT в trade_in_estimates. + + require_consent=True -> здесь мы УЖЕ прошли gate в начале estimate_quality() + (payload.consent is True гарантирован), поэтому пишем durable-доказательство. + require_consent=False (B2B-путь, дефолт для всех прочих вызывающих) -> все + четыре NULL: согласие закрыто договором либо вызывающий вообще не участвует + в consent-контракте (внутренние тесты/скрипты) — доказательство здесь не + собиралось и собираться не должно. + """ + if require_consent: + return { + "consent": True, + "client_ip": client_ip, + "consent_policy_version": _ESTIMATE_CONSENT_POLICY_VERSION, + "consent_text_snapshot": _ESTIMATE_CONSENT_TEXT_SNAPSHOT, + } + return { + "consent": None, + "client_ip": None, + "consent_policy_version": None, + "consent_text_snapshot": None, + } + + # #oblast-D (non-EKB deals-headline-fallback): минимум ДКП-сделок, чтобы # _fetch_dkp_corridor доверял СВОЕЙ street-scoped выборке — иначе (тонкая # конкретная улица небольшого города) виджет расширяется до city-wide (см. @@ -3137,7 +3201,12 @@ def _price_from_inputs( # ── Public ─────────────────────────────────────────────────────────────────── async def estimate_quality( - payload: TradeInEstimateInput, db: Session, created_by: str | None = None + payload: TradeInEstimateInput, + db: Session, + created_by: str | None = None, + client_ip: str | None = None, + *, + require_consent: bool = False, ) -> AggregatedEstimate: """Главная функция — оценка квартиры по реальным данным. @@ -3148,9 +3217,34 @@ async def estimate_quality( AggregatedEstimate с tier classification (T0_per_house / T1_per_street) — frontend может разделять confidence в UI. + Args: + require_consent: ЭТАП 4 B2C consent-before-save gate (keyword-only, + дефолт False — см. _ESTIMATE_CONSENT_* комментарий выше про то, + почему это НЕ выводится из created_by is None). Единственный + реальный вызывающий — app/api/v1/trade_in.py::estimate() — передаёт + True, когда нет X-Authenticated-User (анонимный запрос). + Returns: AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами. + + Raises: + HTTPException(422): require_consent=True без payload.consent=True. + Проверка стоит ПЕРВОЙ строкой тела функции, ДО geocode() и ДО + обоих мест, где адрес попадает в trade_in_estimates (главный + INSERT ниже И _empty_estimate fallback) — адрес физлица не должен + попасть в БД раньше согласия ни при каком исходе оценки. """ + # ЭТАП 4 B2C launch (152-ФЗ) — consent-before-save. require_consent=False + # (дефолт, весь B2B-путь и все внутренние/тестовые вызовы) — эта проверка + # их не касается. require_consent=True (реально анонимный HTTP-запрос, + # см. Args выше) обязан нести явное согласие ПРЕЖДЕ, чем estimate_id будет + # сгенерирован и адрес уйдёт в БД. + if require_consent and not payload.consent: + raise HTTPException( + status_code=422, + detail="consent required for anonymous estimate request", + ) + # 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack # multiple network round-trips + 1s Nominatim rate-limit sleeps). geo: GeocodeResult | None = None @@ -3190,7 +3284,13 @@ async def estimate_quality( # Без координат не можем искать через PostGIS. Возвращаем low confidence. logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address) return await asyncio.to_thread( - _empty_estimate, payload, db, reason="address_not_geocoded", created_by=created_by + _empty_estimate, + payload, + db, + reason="address_not_geocoded", + created_by=created_by, + client_ip=client_ip, + require_consent=require_consent, ) # 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса. @@ -3609,7 +3709,7 @@ async def estimate_quality( # 6. Сохраняем в trade_in_estimates estimate_id = uuid4() now = datetime.now(tz=UTC) - expires_at = now + timedelta(hours=24) + expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours) # #694: когда same-building якорь сработал, headline построен на комплах того # же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean @@ -3682,7 +3782,8 @@ async def estimate_quality( expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, created_by, - expires_at + expires_at, + consent, client_ip, consent_policy_version, consent_text_snapshot ) VALUES ( CAST(:id AS uuid), :address, :lat, :lon, @@ -3702,7 +3803,9 @@ async def estimate_quality( :expected_sold_range_high, :expected_sold_per_m2, :asking_to_sold_ratio, :ratio_basis, :created_by, - :expires_at + :expires_at, + :consent, CAST(:client_ip AS inet), :consent_policy_version, + :consent_text_snapshot ) """ ), @@ -3754,6 +3857,7 @@ async def estimate_quality( "ratio_basis": ratio_basis, "created_by": created_by, "expires_at": expires_at, + **_estimate_consent_persist_fields(require_consent, client_ip), }, ) @@ -6066,16 +6170,27 @@ def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: def _empty_estimate( - payload: TradeInEstimateInput, db: Session, *, reason: str, created_by: str | None = None + payload: TradeInEstimateInput, + db: Session, + *, + reason: str, + created_by: str | None = None, + client_ip: str | None = None, + require_consent: bool = False, ) -> AggregatedEstimate: """Fallback когда нет данных для оценки. Сохраняет запись в БД (confidence='low', пустые analogs/deals), чтобы GET /estimate/{id} не возвращал 404. C-4 security audit. + + ЭТАП 4 B2C: этот путь тоже пишет адрес в trade_in_estimates -- consent-gate в + estimate_quality() уже отработал ДО вызова (require_consent=True подразумевает + payload.consent is True), здесь просто персистим то же consent-доказательство, + что и главный путь (_estimate_consent_persist_fields). """ estimate_id = uuid4() now = datetime.now(tz=UTC) - expires_at = now + timedelta(hours=24) + expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours) db.execute( text( @@ -6090,7 +6205,8 @@ def _empty_estimate( analogs, actual_deals, sources_used, created_by, - expires_at + expires_at, + consent, client_ip, consent_policy_version, consent_text_snapshot ) VALUES ( CAST(:id AS uuid), :address, :area, :rooms, :floor, :total_floors, @@ -6101,7 +6217,9 @@ def _empty_estimate( '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, :created_by, - :expires_at + :expires_at, + :consent, CAST(:client_ip AS inet), :consent_policy_version, + :consent_text_snapshot ) """ ), @@ -6121,6 +6239,7 @@ def _empty_estimate( "explanation": reason, "created_by": created_by, "expires_at": expires_at, + **_estimate_consent_persist_fields(require_consent, client_ip), }, ) db.commit() diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index d6c70869..4165571a 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -348,6 +348,24 @@ async def _job_house_imv_backfill( logger.exception("scheduler: mark_failed crashed run_id=%d", run_id) +# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ─────────────── +async def _job_purge_expired_trade_in_data( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + from app.tasks.purge_expired_trade_in_data import purge_expired_trade_in_data + + batch_size = params.get("batch_size") + max_batches = params.get("max_batches") + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: purge_expired_trade_in_data( + db, run_id, batch_size=batch_size, max_batches=max_batches + ), + ) + + # ── house_dedup_merge — sync destructive merge в executor, owns lifecycle ───── async def _job_house_dedup_merge( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext @@ -424,6 +442,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"), "house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"), "house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"), + "purge_expired_trade_in_data": Handler( + _job_purge_expired_trade_in_data, "purge_expired_trade_in_data" + ), "proxy_healthcheck": Handler( _job_proxy_healthcheck, "proxy_healthcheck", diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py new file mode 100644 index 00000000..17a99508 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -0,0 +1,164 @@ +"""Physically delete expired personal data — ЭТАП 4 B2C retention enforcement (152-ФЗ). + +WHY: + trade_in_estimates.expires_at (и, начиная с migration 193, trade_in_leads.expires_at) + defined a retention window, but neither table had any background job that actually + DELETEd rows once expired -- expires_at was used ONLY as a read-time filter + (GET /estimate/{id}: "AND expires_at > NOW()"). Personal data (address / phone) + outlived its declared lifetime indefinitely, contradicting the retention policy + shown to the user. + +WHAT: + Batched physical DELETE for both tables, run nightly by the kit-scheduler (see + app.services.product_handlers._job_purge_expired_trade_in_data, scrape_schedules + row seeded by migration 193 -- seeded enabled=false, see that migration's docstring + for why). Same architecture as app/tasks/deactivate_stale_avito.py (sync, DB-only, + invoked via run_in_executor from the async kit handler). + + - trade_in_estimates: ON DELETE CASCADE already cleans up estimate_photos + (007_estimate_photos.sql) and avito_imv_evaluations (018_avito_imv_evaluations.sql) + for each deleted estimate. + - trade_in_leads: ON DELETE SET NULL on trade_in_leads.estimate_id (172_trade_in_leads.sql) + means a lead created from a now-purged estimate SURVIVES with estimate_id nulled -- + it has its OWN retention clock (trade_in_leads.expires_at) and its own PII (phone), + purged independently below. + +BATCHING (не единый DELETE по всей таблице): + Each table is drained in batches of `batch_size` rows (default + settings.trade_in_purge_batch_size), each batch its OWN statement + its OWN commit + (bounds lock/transaction duration on a backlog). A run stops draining a table once a + batch returns fewer rows than batch_size (caught up) OR after `max_batches` iterations + (safety cap on total run duration -- any remaining backlog drains over subsequent + nightly runs, not one giant transaction). Idempotent: rows already deleted simply + don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier + committed batches deleted (correct, not rolled back) and mark_failed records the + partial counters reached so far. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.services import scrape_runs as runs_mod + +logger = logging.getLogger(__name__) + +# Safety cap on batches per table per run -- bounds a single scheduled run's total +# duration even if the backlog is much larger than batch_size * max_batches; the +# remainder simply drains on the next nightly run (idempotent, no data loss risk). +_DEFAULT_MAX_BATCHES = 20 + +_DELETE_EXPIRED_ESTIMATES_SQL = text( + """ + DELETE FROM trade_in_estimates + WHERE id IN ( + SELECT id FROM trade_in_estimates + WHERE expires_at < NOW() + ORDER BY expires_at + LIMIT CAST(:batch_size AS int) + ) + """ +) + +_DELETE_EXPIRED_LEADS_SQL = text( + """ + DELETE FROM trade_in_leads + WHERE id IN ( + SELECT id FROM trade_in_leads + WHERE expires_at < NOW() + ORDER BY expires_at + LIMIT CAST(:batch_size AS int) + ) + """ +) + + +def _drain_expired( + db: Session, + stmt: Any, + *, + batch_size: int, + max_batches: int, + label: str, + counters: dict[str, int], + counter_key: str, +) -> None: + """Run `stmt` (one bounded DELETE batch) repeatedly until caught up or capped. + + Commits after EVERY batch -- keeps each individual transaction/lock short even + when the backlog is large. Updates `counters[counter_key]` INCREMENTALLY (not + just once at the end) so that a mid-run exception on a LATER batch still leaves + an accurate count of what was actually deleted-and-committed by earlier batches + -- those rows are gone for real (commit already happened) whether or not this + function ever returns normally. + """ + for batch_num in range(1, max_batches + 1): + result = db.execute(stmt, {"batch_size": batch_size}) + deleted = result.rowcount or 0 + db.commit() + counters[counter_key] += deleted + logger.info( + "purge_expired_trade_in_data: %s batch=%d deleted=%d (running_total=%d)", + label, + batch_num, + deleted, + counters[counter_key], + ) + if deleted < batch_size: + break # caught up -- fewer expired rows left than one batch + + +def purge_expired_trade_in_data( + db: Session, + run_id: int, + *, + batch_size: int | None = None, + max_batches: int | None = None, +) -> dict[str, int]: + """Delete expired rows from trade_in_estimates + trade_in_leads, in bounded batches. + + Sync (invoked via run_in_executor from the kit-scheduler handler, same pattern as + deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed). + + Returns {"estimates_deleted": N, "leads_deleted": M}. + """ + batch_size = batch_size or settings.trade_in_purge_batch_size + max_batches = max_batches or _DEFAULT_MAX_BATCHES + counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0} + try: + _drain_expired( + db, + _DELETE_EXPIRED_ESTIMATES_SQL, + batch_size=batch_size, + max_batches=max_batches, + label="trade_in_estimates", + counters=counters, + counter_key="estimates_deleted", + ) + _drain_expired( + db, + _DELETE_EXPIRED_LEADS_SQL, + batch_size=batch_size, + max_batches=max_batches, + label="trade_in_leads", + counters=counters, + counter_key="leads_deleted", + ) + runs_mod.mark_done(db, run_id, counters) + logger.info( + "purge_expired_trade_in_data run_id=%d done: estimates_deleted=%d leads_deleted=%d", + run_id, + counters["estimates_deleted"], + counters["leads_deleted"], + ) + return counters + except Exception as exc: + logger.exception("purge_expired_trade_in_data run_id=%d failed", run_id) + db.rollback() + runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) + raise diff --git a/tradein-mvp/backend/data/sql/192_trade_in_estimates_consent_proof.sql b/tradein-mvp/backend/data/sql/192_trade_in_estimates_consent_proof.sql new file mode 100644 index 00000000..78ff74e5 --- /dev/null +++ b/tradein-mvp/backend/data/sql/192_trade_in_estimates_consent_proof.sql @@ -0,0 +1,93 @@ +-- 192_trade_in_estimates_consent_proof.sql +-- ЭТАП 4 B2C launch — правовая рамка для анонимных пользователей (152-ФЗ). +-- +-- WHY: +-- trade_in_estimates.address (NOT NULL) сохраняется на КАЖДОЙ оценке, но до +-- сих пор в кодовой базе не было ни единой проверки согласия ДО этого +-- сохранения — ни на уровне схемы, ни на уровне API. Для авторизованного +-- B2B-пилота это было терпимо (согласие закрыто договором), но сегодняшняя +-- схема совершенно не готова к анонимному B2C-пользователю "с улицы": для +-- него согласия не существует вообще, ни в БД, ни в договоре. +-- +-- Прецедент уже есть и работает: migration 182 добавила на trade_in_leads +-- durable-доказательство согласия (client_ip / consent_policy_version / +-- consent_text_snapshot) вместо голого boolean + audit-лога. Этот файл +-- переиспользует РОВНО ТУ ЖЕ форму для trade_in_estimates — согласованность +-- схемы для одного и того же понятия (152-ФЗ proof-of-consent) в двух +-- соседних таблицах важнее гипотетической экономии на отдельной events- +-- таблице (см. разбор формы хранения в PR/задаче ЭТАП 4). +-- +-- WHAT: +-- Четыре nullable-колонки на trade_in_estimates: +-- - consent (boolean) — True для анонимных запросов, что +-- прошли consent-gate в +-- estimate_quality() (app/services/ +-- estimator.py). NULL для B2B-пилотов +-- (created_by задан) — их согласие +-- закрыто договором, НЕ UI-чекбоксом, +-- и мы НЕ подделываем доказательство, +-- которое реально не собиралось. +-- - client_ip (inet) — клиентский IP анонимного запроса на +-- момент согласия. +-- - consent_policy_version (text) — снимок _ESTIMATE_CONSENT_POLICY_VERSION +-- (estimator.py) на момент согласия. +-- - consent_text_snapshot (text) — снимок точного текста согласия, +-- показанного пользователю +-- (_ESTIMATE_CONSENT_TEXT_SNAPSHOT). +-- +-- CHECK-констрейнт: consent IS NULL OR consent IS TRUE — на уровне схемы +-- защищает от того, чтобы False-согласие когда-либо попало в БД (сама +-- проверка в estimate_quality() уже не пускает False дальше 422, это +-- defense-in-depth на случай будущего кода, который забудет про gate). +-- +-- Индекс на expires_at — обслуживает будущую retention-задачу +-- purge_expired_trade_in_data (см. migration 193), которая физически +-- удаляет строки, чей expires_at истёк (сегодня expires_at используется +-- ТОЛЬКО как read-time фильтр, см. GET /estimate/{id}: "AND expires_at > +-- NOW()" — без индекса такой batched-DELETE делал бы full scan таблицы +-- на каждый ночной прогон). +-- +-- IDEMPOTENCY / SAFETY: +-- - ADD COLUMN IF NOT EXISTS x4 — безопасный re-run, все nullable, без +-- DEFAULT, без backfill (существующие строки остаются NULL — честное +-- отражение того, что доказательство согласия для них НЕ собиралось, +-- не искусственная порча схемы). +-- - CHECK-констрейнт добавлен через DO-блок с проверкой pg_constraint по +-- имени (Postgres не поддерживает `ADD CONSTRAINT IF NOT EXISTS` +-- напрямую) — паттерн 1:1 из 189_account_estimate_usage_nonnegative.sql. +-- - CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- - Чисто additive: ничего существующего не читается/не переписывается. +-- +-- Dependencies: 001_trade_in_estimates.sql (таблица), +-- 083_trade_in_estimates_created_by.sql (created_by, использован в gate), +-- 182_trade_in_leads_consent_proof.sql (форма-прецедент для trade_in_leads). + +BEGIN; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS consent boolean, + ADD COLUMN IF NOT EXISTS client_ip inet, + ADD COLUMN IF NOT EXISTS consent_policy_version text, + ADD COLUMN IF NOT EXISTS consent_text_snapshot text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'trade_in_estimates_consent_not_false' + ) THEN + ALTER TABLE trade_in_estimates + ADD CONSTRAINT trade_in_estimates_consent_not_false + CHECK (consent IS NULL OR consent IS TRUE); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS trade_in_estimates_expires_at_idx + ON trade_in_estimates (expires_at); + +COMMENT ON COLUMN trade_in_estimates.consent IS '152-ФЗ proof-of-consent (ЭТАП 4 B2C): TRUE для анонимных запросов, прошедших consent-gate в estimate_quality(). NULL для B2B-пилотов (created_by задан) — согласие закрыто договором, доказательство здесь не собирается.'; +COMMENT ON COLUMN trade_in_estimates.client_ip IS '152-ФЗ proof-of-consent: клиентский IP анонимного запроса на момент согласия (см. app/api/v1/trade_in.py::estimate, _client_ip). NULL для B2B-пилотов.'; +COMMENT ON COLUMN trade_in_estimates.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _ESTIMATE_CONSENT_POLICY_VERSION (app/services/estimator.py) на момент согласия.'; +COMMENT ON COLUMN trade_in_estimates.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия на ОЦЕНКУ, показанного пользователю (_ESTIMATE_CONSENT_TEXT_SNAPSHOT) — отдельный текст от trade_in_leads.consent_text_snapshot (тот про согласие на контакт-заявку, другой предмет обработки).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/193_trade_in_privacy_retention.sql b/tradein-mvp/backend/data/sql/193_trade_in_privacy_retention.sql new file mode 100644 index 00000000..822a0526 --- /dev/null +++ b/tradein-mvp/backend/data/sql/193_trade_in_privacy_retention.sql @@ -0,0 +1,93 @@ +-- 193_trade_in_privacy_retention.sql +-- ЭТАП 4 B2C launch — retention enforcement (152-ФЗ): срок хранения ДОЛЖЕН +-- приводить к физическому удалению, а не быть декоративным полем. +-- +-- WHY: +-- trade_in_estimates.expires_at существовал (migration 004), но применялся +-- ТОЛЬКО как read-time фильтр (GET /estimate/{id}: "AND expires_at > NOW()") +-- — ни одна фоновая задача не удаляла строки после истечения TTL. Данные +-- физлица (адрес) жили в БД бессрочно вопреки декларированному сроку. +-- trade_in_leads было ещё хуже: там вообще НЕ было понятия TTL/expires_at — +-- телефон + согласие хранились бессрочно с момента создания таблицы +-- (172_trade_in_leads.sql). +-- +-- WHAT: +-- 1. trade_in_leads.expires_at (timestamptz NOT NULL) — backfill для +-- существующих строк = created_at + 180 дней (тот же default, что +-- settings.trade_in_lead_retention_days в app/core/config.py на момент +-- этой миграции; 180 дней = рабочий MVP-default для НЕконвертированных +-- маркетинговых лидов, см. обоснование в config.py — конкретный +-- юридически обоснованный срок хранения это решение DPO/юриста, не +-- инженера). Новые строки получают expires_at на insert-time +-- (app/api/v1/lead.py, тем же паттерном, что trade_in_estimates). +-- 2. Индекс на trade_in_leads.expires_at — для batched-DELETE ниже. +-- 3. scrape_schedules seed: purge_expired_trade_in_data — ночная задача +-- (app/tasks/purge_expired_trade_in_data.py, kit-handler в +-- app/services/product_handlers.py), физически удаляющая ИСТЁКШИЕ +-- строки в trade_in_estimates И trade_in_leads пачками (batch_size из +-- default_params, лимит max_batches за один прогон — см. таск-докстринг). +-- ON DELETE CASCADE (007_estimate_photos, 018_avito_imv_evaluations) и +-- ON DELETE SET NULL (172_trade_in_leads.estimate_id) уже подчищают +-- зависимые таблицы автоматически — этот файл их не трогает. +-- +-- Seeded с enabled=false (тот же осторожный паттерн, что +-- 175_scrape_schedules_seed_domclick_detail_backfill.sql): это ПЕРВАЯ +-- автоматическая задача физического DELETE персональных данных в trade-in — +-- заслуживает supervised первого прогона (смотри логи/counters вручную) +-- перед тем, как доверить её расписанию. Включение — отдельный ручной шаг +-- (UPDATE scrape_schedules SET enabled=true WHERE source= +-- 'purge_expired_trade_in_data'). +-- +-- IDEMPOTENCY / SAFETY: +-- - ADD COLUMN IF NOT EXISTS + UPDATE ... WHERE expires_at IS NULL (no-op на +-- повторном прогоне, все строки уже проставлены) + +-- ALTER COLUMN ... SET NOT NULL (идемпотентно само по себе — Postgres не +-- ошибается на повторной установке уже действующего NOT NULL). +-- - CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- - INSERT ... ON CONFLICT (source) DO NOTHING — безопасный re-run seed'а. +-- +-- Dependencies: 172_trade_in_leads.sql (таблица), 052_scrape_schedules.sql +-- (scrape_schedules), 192_trade_in_estimates_consent_proof.sql (соседняя +-- часть той же ЭТАП 4 инициативы — индекс на trade_in_estimates.expires_at +-- уже создан там). + +BEGIN; + +ALTER TABLE trade_in_leads + ADD COLUMN IF NOT EXISTS expires_at timestamptz; + +UPDATE trade_in_leads +SET expires_at = created_at + interval '180 days' +WHERE expires_at IS NULL; + +ALTER TABLE trade_in_leads + ALTER COLUMN expires_at SET NOT NULL; + +CREATE INDEX IF NOT EXISTS trade_in_leads_expires_at_idx + ON trade_in_leads (expires_at); + +COMMENT ON COLUMN trade_in_leads.expires_at IS 'ЭТАП 4 B2C (152-ФЗ): срок хранения лида. Backfill для legacy-строк = created_at + 180 дней; новые строки считаются на insert-time из settings.trade_in_lead_retention_days (app/api/v1/lead.py). Физическое удаление после истечения — app/tasks/purge_expired_trade_in_data.py.'; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'purge_expired_trade_in_data', + false, + 2, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"batch_size": 500, "max_batches": 20}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (replaces cron-script setup). Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), yandex_address_backfill (#855, EKB pilot), sber_index_pull (#887, monthly), rosreestr_quarter_poll (#888, monthly), cian_city_sweep (dormant, #973), yandex_newbuilding_sweep (dormant, #974), geocode_missing_listings (#1: listings geom backfill, all sources), avito_detail_backfill (#1551: nightly detail-enrichment backfill for legacy avito listings), domclick_detail_backfill (#2000: nightly Layer B detail-enrichment backfill for domklik listings, cookie-injection + QRATOR-aware, disabled by default until smoke-tested), purge_expired_trade_in_data (ЭТАП 4 B2C: nightly batched physical DELETE of expired trade_in_estimates/trade_in_leads rows, disabled by default until a supervised first run).'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py new file mode 100644 index 00000000..b87af5ae --- /dev/null +++ b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py @@ -0,0 +1,89 @@ +"""ЭТАП 4 B2C launch — consent-text sync guard (part D). + +WHY: + _CONSENT_TEXT_SNAPSHOT in app/api/v1/lead.py is a durable 152-ФЗ + proof-of-consent: it must be the EXACT text a user actually saw and agreed + to. Before this test, the only thing keeping it in sync with the real + frontend checkbox label (LeadForm.tsx) was a code COMMENT ("Должен + ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx"). A comment cannot fail CI + -- a frontend copy edit could silently drift from the backend snapshot, + and every future lead's "proof" would then misrepresent what the user + actually saw. + +WHAT: + Extract the actual consent-checkbox label text straight out of + LeadForm.tsx (regex, no JSX parser needed -- there is exactly one + in the file today) and assert it matches _CONSENT_TEXT_SNAPSHOT byte-for- + byte after whitespace normalisation (JSX text nodes wrap across source + lines; the DOM-rendered text collapses that to single spaces). If someone + edits ONE side without the other, this test fails. + + NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT + in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet + -- the anonymous /estimate flow isn't live (rbac_guard still requires + X-Authenticated-User on every non-public path, see app/core/rbac.py). When + that flow ships its own consent checkbox, add a second sync test here + mirroring this one -- do NOT rely on a comment for that pairing either. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_FRONTEND_LEAD_FORM = ( + Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "components" + / "trade-in" + / "v2" + / "LeadForm.tsx" +) + + +def _extract_span_text(tsx_source: str) -> str: + """Pull the text content of the (single) ... in LeadForm.tsx, + whitespace-normalised the same way a browser collapses JSX text-node + whitespace when rendering (multiple lines/indentation -> single spaces). + """ + match = re.search(r"\s*(.*?)\s*", tsx_source, re.DOTALL) + assert match is not None, "no found in LeadForm.tsx -- consent label markup changed" + return re.sub(r"\s+", " ", match.group(1)).strip() + + +def test_frontend_lead_form_exists() -> None: + assert _FRONTEND_LEAD_FORM.is_file(), f"missing frontend file: {_FRONTEND_LEAD_FORM}" + + +def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None: + """The whole point: this FAILS if lead.py._CONSENT_TEXT_SNAPSHOT and + LeadForm.tsx's checkbox label ever diverge -- no longer just a comment.""" + from app.api.v1.lead import _CONSENT_TEXT_SNAPSHOT + + frontend_text = _extract_span_text(_FRONTEND_LEAD_FORM.read_text(encoding="utf-8")) + backend_text = re.sub(r"\s+", " ", _CONSENT_TEXT_SNAPSHOT).strip() + + assert frontend_text == backend_text, ( + "consent text drift detected between app/api/v1/lead.py._CONSENT_TEXT_SNAPSHOT " + "and frontend/src/components/trade-in/v2/LeadForm.tsx checkbox label -- the " + "152-ФЗ proof-of-consent snapshot no longer matches what users actually see. " + "Bump _CONSENT_POLICY_VERSION and update _CONSENT_TEXT_SNAPSHOT together with " + "any frontend copy change.\n" + f" frontend: {frontend_text!r}\n" + f" backend: {backend_text!r}" + ) + + +def test_extract_span_text_helper_is_whitespace_insensitive() -> None: + """Sanity check on the extraction helper itself, independent of the real file.""" + sample = """ + + Line one + Line two + + """ + assert _extract_span_text(sample) == "Line one Line two" diff --git a/tradein-mvp/backend/tests/test_data_erasure.py b/tradein-mvp/backend/tests/test_data_erasure.py new file mode 100644 index 00000000..ef2ec46e --- /dev/null +++ b/tradein-mvp/backend/tests/test_data_erasure.py @@ -0,0 +1,160 @@ +"""ЭТАП 4 B2C launch — right-to-erasure mechanism (part C). + +Covers app/services/data_erasure.py: + - at least one identifier required (ValueError, no db.execute at all) + - username (B2B pilot): estimates + their leads + web_support_threads deleted + - estimate_ids only (anonymous, has the link/PDF): estimates + linked leads deleted, + web_support/tg_support untouched (no username = nothing to key them by) + - phone only: only leads deleted (no estimate/support action) + - tg_chat_id only: only tg_support deleted (anonymous Telegram-support path) + - ORDER: leads are captured/deleted BEFORE estimates (estimate_id FK is + ON DELETE SET NULL -- deleting estimates first would orphan the join) + - commits once at the end +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import data_erasure + + +class _Result: + def __init__(self, rowcount: int = 0, scalar_ids: list[Any] | None = None) -> None: + self.rowcount = rowcount + self._scalar_ids = scalar_ids or [] + + def scalars(self) -> SimpleNamespace: + return SimpleNamespace(all=lambda: self._scalar_ids) + + +def _sql_of(call: Any) -> str: + stmt = call.args[0] + return str(getattr(stmt, "text", stmt)) + + +def test_requires_at_least_one_identifier() -> None: + db = MagicMock() + with pytest.raises(ValueError, match="at least one identifier"): + data_erasure.erase_person_data(db) + assert not db.execute.called + assert not db.commit.called + + +def test_erase_by_username_deletes_estimates_leads_and_web_support() -> None: + db = MagicMock() + owned_id = uuid4() + db.execute.side_effect = [ + _Result(scalar_ids=[owned_id]), # SELECT id FROM trade_in_estimates WHERE created_by + _Result(rowcount=2), # DELETE FROM trade_in_leads + _Result(rowcount=1), # DELETE FROM trade_in_estimates + _Result(rowcount=3), # DELETE FROM web_support_threads + ] + + out = data_erasure.erase_person_data(db, username="kopylov") + + assert out == { + "trade_in_estimates_deleted": 1, + "trade_in_leads_deleted": 2, + "web_support_deleted": 3, + "tg_support_deleted": 0, + } + assert db.commit.called + + calls = db.execute.call_args_list + assert "SELECT id FROM trade_in_estimates" in _sql_of(calls[0]) + assert "created_by" in _sql_of(calls[0]) + assert "DELETE FROM trade_in_leads" in _sql_of(calls[1]) + assert "DELETE FROM trade_in_estimates" in _sql_of(calls[2]) + assert "DELETE FROM web_support_threads" in _sql_of(calls[3]) + # estimate_ids captured from the SELECT reach the estimates DELETE. + estimates_delete_params = calls[2].args[1] + assert str(owned_id) in estimates_delete_params["ids"] + + +def test_leads_deleted_before_estimates_order() -> None: + """FK trade_in_leads.estimate_id is ON DELETE SET NULL -- capturing/deleting + leads must happen BEFORE the estimates DELETE, else the join key is gone.""" + db = MagicMock() + owned_id = uuid4() + db.execute.side_effect = [ + _Result(scalar_ids=[owned_id]), + _Result(rowcount=0), + _Result(rowcount=1), + _Result(rowcount=0), + ] + data_erasure.erase_person_data(db, username="kopylov") + calls = db.execute.call_args_list + leads_idx = next(i for i, c in enumerate(calls) if "DELETE FROM trade_in_leads" in _sql_of(c)) + estimates_idx = next( + i for i, c in enumerate(calls) if "DELETE FROM trade_in_estimates" in _sql_of(c) + ) + assert leads_idx < estimates_idx + + +def test_erase_by_estimate_ids_only_no_web_or_tg_support_touched() -> None: + db = MagicMock() + eid = uuid4() + db.execute.side_effect = [ + _Result(rowcount=1), # DELETE FROM trade_in_leads (matches estimate_id) + _Result(rowcount=1), # DELETE FROM trade_in_estimates + ] + + out = data_erasure.erase_person_data(db, estimate_ids=[eid]) + + assert out == { + "trade_in_estimates_deleted": 1, + "trade_in_leads_deleted": 1, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + assert db.execute.call_count == 2 # no username -> no SELECT, no web_support DELETE + + +def test_erase_by_phone_only_touches_only_leads() -> None: + db = MagicMock() + db.execute.side_effect = [_Result(rowcount=1)] # DELETE FROM trade_in_leads WHERE phone=... + + out = data_erasure.erase_person_data(db, phone="+79123456789") + + assert out == { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 1, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + assert db.execute.call_count == 1 + params = db.execute.call_args_list[0].args[1] + assert params["phone"] == "+79123456789" + assert params["ids"] == [] + + +def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None: + """Anonymous person with NO username, NO estimate link, NO lead phone -- but + they DID message @MERAsupport_bot -- can still be identified by their own + Telegram chat_id (see module docstring: not spoofable by a third party).""" + db = MagicMock() + db.execute.side_effect = [ + _Result(rowcount=0), # DELETE FROM trade_in_leads (no ids, no phone -> matches nothing) + _Result(rowcount=5), # DELETE FROM tg_support_users + ] + + out = data_erasure.erase_person_data(db, tg_chat_id=123456789) + + assert out == { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 0, + "web_support_deleted": 0, + "tg_support_deleted": 5, + } + calls = db.execute.call_args_list + assert "DELETE FROM tg_support_users" in _sql_of(calls[-1]) + assert calls[-1].args[1]["chat_id"] == 123456789 diff --git a/tradein-mvp/backend/tests/test_estimate_consent_gate.py b/tradein-mvp/backend/tests/test_estimate_consent_gate.py new file mode 100644 index 00000000..28a3d13a --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_consent_gate.py @@ -0,0 +1,319 @@ +"""ЭТАП 4 B2C launch — consent-before-save gate on trade_in_estimates (part A). + +Covers: + - require_consent=True + payload.consent missing/False -> HTTPException(422), + raised BEFORE geocode() is ever touched and BEFORE any db.execute call + (gate is literally the first statement in estimate_quality()). + - require_consent=True + payload.consent=True -> proceeds, and the eventual + INSERT carries consent=True / client_ip / policy version / text snapshot. + - require_consent=True + consent=True but geocode fails -> the + _empty_estimate() fallback INSERT *also* carries the same consent proof + (both places an address can reach trade_in_estimates are covered). + - require_consent defaults to False (regression guard): a bare + estimate_quality(payload, db) call -- exactly the shape used by ~90 other + estimator tests that don't care about auth/consent at all -- is completely + unaffected. This is the whole reason the gate keys off an explicit + keyword-only flag instead of `created_by is None`: the first version of + this gate DID key off created_by and broke 92 unrelated tests across the + estimator test suite (every one of them calls estimate_quality(payload, db) + with created_by defaulting to None, which used to mean nothing). + - the real (only) production caller, app/api/v1/trade_in.py::estimate(), + passes require_consent=(x_authenticated_user is None) -- verified via source + inspection, since exercising it live would require a full FastAPI app + fixture (rbac_guard is DB/env-heavy and out of scope for this offline test). + +Style mirrors tests/test_estimator_client_coords.py + test_estimator_event_loop_2207.py +(offline, db=MagicMock(), downstream helpers patched). +""" + +from __future__ import annotations + +import contextlib +import inspect +import os +import re +from pathlib import Path +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pytest +from fastapi import HTTPException + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_192 = _SQL_DIR / "192_trade_in_estimates_consent_proof.sql" + + +def _make_payload(**overrides: Any) -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + base: dict[str, Any] = dict(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1) + base.update(overrides) + return TradeInEstimateInput(**base) + + +def _make_fake_geo() -> Any: + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Тестовая, 1", + provider="nominatim", + ) + + +def _downstream_patches(geocode_mock: Any) -> tuple[Any, ...]: + """Offline mocks so estimate_quality runs to completion (mirrors 2207 test's set).""" + return ( + patch("app.services.estimator.geocode", new=geocode_mock), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ) + + +def _find_insert_call(db: MagicMock, table_marker: str) -> dict[str, Any]: + for call in db.execute.call_args_list: + stmt = call.args[0] + sql = str(getattr(stmt, "text", stmt)) + if f"INSERT INTO {table_marker}" in sql: + return call.args[1] + raise AssertionError( + f"no INSERT INTO {table_marker} call captured; calls={db.execute.call_args_list}" + ) + + +# ── require_consent=True, no consent -> 422 BEFORE any work ─────────────────── + + +def test_require_consent_without_payload_consent_raises_422_before_geocode() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # consent defaults to None + + async def _run() -> None: + with patch("app.services.estimator.geocode") as geocode_mock: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + geocode_mock.assert_not_called() + + anyio.run(_run) + assert not db.execute.called, "gate must precede ANY db write" + + +def test_require_consent_with_payload_consent_false_raises_422() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=False) + + async def _run() -> None: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + + anyio.run(_run) + assert not db.execute.called + + +# ── require_consent=True, consent given -> proceeds, proof persisted ────────── + + +def test_require_consent_with_payload_consent_persists_proof() -> None: + from app.services.estimator import _ESTIMATE_CONSENT_POLICY_VERSION as POLICY_VERSION + from app.services.estimator import _ESTIMATE_CONSENT_TEXT_SNAPSHOT as TEXT_SNAPSHOT + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=True) + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality( + payload, db, client_ip="203.0.113.9", require_consent=True + ) + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is True + assert params["client_ip"] == "203.0.113.9" + assert params["consent_policy_version"] == POLICY_VERSION + assert params["consent_text_snapshot"] == TEXT_SNAPSHOT + assert params["created_by"] is None + + +# ── require_consent=True, consent given, geocode fails -> _empty_estimate too ─ + + +def test_empty_estimate_fallback_persists_proof_when_consent_required() -> None: + from app.services.estimator import _ESTIMATE_CONSENT_POLICY_VERSION as POLICY_VERSION + from app.services.estimator import _ESTIMATE_CONSENT_TEXT_SNAPSHOT as TEXT_SNAPSHOT + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=True) + geocode_mock = AsyncMock(return_value=None) # geocode failure -> _empty_estimate path + + async def _run() -> Any: + with patch("app.services.estimator.geocode", new=geocode_mock): + return await estimate_quality( + payload, db, client_ip="198.51.100.4", require_consent=True + ) + + result = anyio.run(_run) + assert result.n_analogs == 0 + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is True + assert params["client_ip"] == "198.51.100.4" + assert params["consent_policy_version"] == POLICY_VERSION + assert params["consent_text_snapshot"] == TEXT_SNAPSHOT + + +def test_empty_estimate_fallback_gate_still_blocks_without_consent() -> None: + """Gate precedes _empty_estimate too -- geocode is never even reached.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent + + async def _run() -> None: + with patch("app.services.estimator.geocode") as geocode_mock: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + geocode_mock.assert_not_called() + + anyio.run(_run) + + +# ── require_consent defaults False -> zero blast radius on existing callers ─── + + +def test_require_consent_defaults_false() -> None: + from app.services.estimator import estimate_quality + + sig = inspect.signature(estimate_quality) + assert sig.parameters["require_consent"].default is False + assert sig.parameters["require_consent"].kind == inspect.Parameter.KEYWORD_ONLY + + +def test_bare_call_without_require_consent_is_unaffected() -> None: + """The exact call shape used by ~90 other estimator tests + (estimate_quality(payload, db), no created_by/require_consent at all) -- + must keep working with NO consent field on the payload whatsoever.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent field + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality(payload, db) + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is None + assert params["client_ip"] is None + assert params["consent_policy_version"] is None + assert params["consent_text_snapshot"] is None + + +# ── B2B pilot (created_by set, require_consent left False) -> unaffected ────── + + +def test_b2b_pilot_without_consent_field_unaffected() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent field at all -- mirrors real pilot frontend + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality(payload, db, created_by="kopylov") + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["created_by"] == "kopylov" + assert params["consent"] is None + assert params["client_ip"] is None + assert params["consent_policy_version"] is None + assert params["consent_text_snapshot"] is None + + +# ── production wiring: app/api/v1/trade_in.py passes require_consent correctly ─ + + +def test_api_handler_wires_require_consent_from_auth_header() -> None: + """Source-inspection guard: the ONLY production caller of estimate_quality + must derive require_consent from the ABSENCE of X-Authenticated-User, not + hardcode True/False. Cheaper and more robust than spinning up a full app + + rbac_guard fixture just to exercise one kwarg's wiring.""" + from app.api.v1 import trade_in as trade_in_module + + src = re.sub(r"\s+", " ", inspect.getsource(trade_in_module.estimate)) + assert "require_consent=x_authenticated_user is None" in src + + +# ── Migration 192 sanity ──────────────────────────────────────────────────────── + + +def test_migration_192_exists() -> None: + assert _MIGRATION_192.is_file(), f"missing migration: {_MIGRATION_192}" + + +def test_migration_192_is_transactional() -> None: + sql = _MIGRATION_192.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_192_is_idempotent() -> None: + sql = _MIGRATION_192.read_text("utf-8") + assert "ADD COLUMN IF NOT EXISTS consent" in sql + assert "ADD COLUMN IF NOT EXISTS client_ip" in sql + assert "CREATE INDEX IF NOT EXISTS" in sql + assert "pg_constraint" in sql # DO-block guard, not bare ADD CONSTRAINT + + +def test_migration_192_no_psycopg_trap() -> None: + sql = _MIGRATION_192.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +def test_migration_192_check_constraint_allows_null_or_true() -> None: + sql = _MIGRATION_192.read_text("utf-8") + assert "consent IS NULL OR consent IS TRUE" in sql diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py new file mode 100644 index 00000000..e32b4570 --- /dev/null +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -0,0 +1,232 @@ +"""ЭТАП 4 B2C launch — retention -> physical deletion task (part B). + +Covers app/tasks/purge_expired_trade_in_data.py: + - batched DELETE (not a single unbounded DELETE), each batch its own commit + - stops draining a table once a batch returns fewer rows than batch_size + - safety cap (max_batches) bounds a single run even on a huge backlog + - both tables (trade_in_estimates, trade_in_leads) get drained + - failure path: rollback + mark_failed with partial counters, exception re-raised + - SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap + +Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched +runs_mod.mark_done/mark_failed). +""" + +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 purge_expired_trade_in_data as task_mod + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_193 = _SQL_DIR / "193_trade_in_privacy_retention.sql" + + +class _FakeResult: + def __init__(self, rowcount: int) -> None: + self.rowcount = rowcount + + +class _FakeDB: + """Pops rowcounts in call order -- caller supplies the exact sequence expected.""" + + def __init__(self, rowcounts: list[int]) -> None: + self._rowcounts = list(rowcounts) + self.executed: list[tuple[Any, Any]] = [] + self.commits = 0 + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + self.executed.append((stmt, params)) + return _FakeResult(self._rowcounts.pop(0)) + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + self.rolled_back = True + + +def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + marked: dict[str, Any] = {} + monkeypatch.setattr( + task_mod.runs_mod, + "mark_done", + lambda _db, run_id, counters: marked.update( + kind="done", run_id=run_id, counters=dict(counters) + ), + ) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda _db, run_id, err, counters: marked.update( + kind="failed", run_id=run_id, err=err, counters=dict(counters) + ), + ) + return marked + + +# ── batching behaviour ────────────────────────────────────────────────────── + + +def test_stops_when_batch_below_size(monkeypatch: pytest.MonkeyPatch) -> None: + marked = _patch_runs(monkeypatch) + # estimates: one batch of 3 (< batch_size=10) -> stop. leads: one batch of 0 -> stop. + db = _FakeDB([3, 0]) + out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 3, "leads_deleted": 0} + assert len(db.executed) == 2 + assert db.commits == 2 + assert marked["counters"] == out + + +def test_loops_until_below_batch_size(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + # estimates: 5,5,2 (batch_size=5) -> 12 total, 3 batches. leads: 5,1 -> 6 total, 2 batches. + db = _FakeDB([5, 5, 2, 5, 1]) + out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 12, "leads_deleted": 6} + assert len(db.executed) == 5 + assert db.commits == 5, "each batch must commit independently, not one final commit" + + +def test_respects_max_batches_cap(monkeypatch: pytest.MonkeyPatch) -> None: + """Every batch returns a FULL batch_size (never 'caught up') -- only the + max_batches safety cap can stop the loop. Proves the cap is enforced, not + just coincidentally matching a 'caught up' condition.""" + _patch_runs(monkeypatch) + db = _FakeDB([5, 5, 5, 5, 5, 5]) # exactly max_batches=3 per table, no more + out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type] + assert out == {"estimates_deleted": 15, "leads_deleted": 15} + assert len(db.executed) == 6 # 3 (estimates) + 3 (leads), NOT unbounded + + +def test_default_batch_size_and_max_batches_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately + task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type] + _stmt, params = db.executed[0] + assert params is not None + assert params["batch_size"] == task_mod.settings.trade_in_purge_batch_size + + +# ── table coverage / SQL shape ────────────────────────────────────────────── + + +def test_drains_both_tables_in_order(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) + task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type] + first_sql = str(getattr(db.executed[0][0], "text", db.executed[0][0])) + second_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + assert "trade_in_estimates" in first_sql + assert "trade_in_leads" in second_sql + + +def test_estimates_sql_is_delete_not_update() -> None: + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "DELETE FROM trade_in_estimates" in sql + assert "UPDATE" not in sql.upper() + assert "expires_at < NOW()" in sql + assert "ORDER BY expires_at" in sql + assert "LIMIT CAST(:batch_size AS int)" in sql + assert not re.search(r":\w+::", sql) + + +def test_leads_sql_is_delete_not_update() -> None: + sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text + assert "DELETE FROM trade_in_leads" in sql + assert "UPDATE" not in sql.upper() + assert "expires_at < NOW()" in sql + assert "ORDER BY expires_at" in sql + assert not re.search(r":\w+::", sql) + + +def test_sql_does_not_delete_whole_table_unbounded() -> None: + """Neither statement is a bare `DELETE FROM table` -- both scope via a + subselect + LIMIT batch.""" + statements = ( + task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text, + task_mod._DELETE_EXPIRED_LEADS_SQL.text, + ) + for sql in statements: + assert "WHERE id IN (" in sql + assert "LIMIT" in sql + + +# ── failure path ───────────────────────────────────────────────────────────── + + +def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: + marked = _patch_runs(monkeypatch) + + class _BoomDB(_FakeDB): + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + # First batch (estimates) succeeds and commits; second call (still + # draining estimates, or first leads call) explodes. + if len(self.executed) >= 1: + raise RuntimeError("db exploded") + return super().execute(stmt, params) + + db = _BoomDB([5]) # only ONE successful batch before the boom + with pytest.raises(RuntimeError, match="db exploded"): + task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type] + + assert db.rolled_back is True + assert marked["kind"] == "failed" + assert marked["run_id"] == 6 + # Partial progress preserved in the counters passed to mark_failed (first + # estimates batch of 5 already committed before the boom on the 2nd call). + assert marked["counters"]["estimates_deleted"] == 5 + assert marked["counters"]["leads_deleted"] == 0 + + +def test_idempotent_zero_rowcount_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Re-running against an already-drained backlog just deletes 0 rows, cleanly.""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) + out = task_mod.purge_expired_trade_in_data(db, run_id=7, batch_size=500, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 0, "leads_deleted": 0} + assert marked["kind"] == "done" + + +# ── migration 193 ──────────────────────────────────────────────────────────── + + +def test_migration_193_exists() -> None: + assert _MIGRATION_193.is_file(), f"missing migration: {_MIGRATION_193}" + + +def test_migration_193_is_transactional() -> None: + sql = _MIGRATION_193.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_193_backfills_and_sets_not_null() -> None: + sql = _MIGRATION_193.read_text("utf-8") + assert "ADD COLUMN IF NOT EXISTS expires_at" in sql + assert "WHERE expires_at IS NULL" in sql + assert "SET NOT NULL" in sql + assert "180 days" in sql + + +def test_migration_193_seeds_purge_schedule_disabled_by_default() -> None: + sql = _MIGRATION_193.read_text("utf-8") + assert "'purge_expired_trade_in_data'" in sql + assert "ON CONFLICT (source) DO NOTHING" in sql + # Seeded disabled -- first automated PII-DELETE job in trade-in deserves a + # supervised first run before the scheduler can trigger it unattended. + assert re.search(r"'purge_expired_trade_in_data',\s*\n\s*false,", sql) + + +def test_migration_193_no_psycopg_trap() -> None: + sql = _MIGRATION_193.read_text("utf-8") + assert not re.search(r":\w+::", sql) From 0b54b969844c3e2e1e1df9506b0d44be86d25b73 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 22:35:51 +0500 Subject: [PATCH 002/130] =?UTF-8?q?fix(tradein/scraper):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BF=D1=83=D1=81=D0=BA=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=BD=D0=B0=20=D1=81=D0=BE=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D0=BE=D0=BC=20skipped=20(#2658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пропуск наступившего окна был немым: logger + сдвиг next_run_at, ни строки в scrape_runs, ни изменения last_run_at. cian_history_backfill так простоял 37 дней на протухших куках Циана и снаружи выглядел работающим — next_run_at исправно двигался вперёд, а docker-логи с warning'ом терялись на каждом редеплое. Статус 'skipped' заведён ещё миграцией 015 и локализован во фронте («пропущено»), но в проде имел 0 строк — механизм построен и ни разу не использован. Задействуем его во всех пяти местах, где расписание пропускалось без следа: kit `_claim_run` (already_running / concurrent_claim / running_appeared_under_lock), kit `scheduler_loop` (unknown_source) и продуктовый cian `pre_claim`. Причина — слаг в `error`, по нему «нет кук» отличается от «уже бежит» запросом, а не грепом логов. Подряд идущие одинаковые пропуски схлопываются в одну строку со счётчиком `counters.skips`: «уже бежит» и «неизвестный source» не двигают next_run_at и иначе плодили бы строку каждый тик (60 с). Алерт про куки жил в недостижимой ветке: он стоял там, где verify_session вернул None, а на протухших куках load_session сам фильтрует expires_at_estimate > NOW() и отдаёт None ещё в первой, немой ветке. Теперь алерт в обеих ветках и через logger.error — в scraper-контейнере GlitchTip поднят с LoggingIntegration (event_level=ERROR), поэтому прежний capture_message(level="warning") событием не становился. Плюс предупреждение ЗАРАНЕЕ (COOKIE_EXPIRY_WARN_DAYS=5) в том же pre_claim: обновление кук — ручная операция, алерт по факту протухания приходит, когда сбор уже встал. Монитор нулевых прогонов (#2625) не трогаем: обе alert-выборки отбирают failed/banned/done/cancelled, поэтому 'skipped' в стрик не попадает и его не прерывает — пропуск не «прогон вернул ноль лотов», смешивать нельзя. --- .../backend/app/services/cian_session.py | 29 ++ .../backend/app/services/product_handlers.py | 94 +++- .../tests/test_scrape_skip_visibility.py | 480 ++++++++++++++++++ .../test_scraper_kit_scheduler_parity.py | 12 + .../src/scraper_kit/orchestration/runs.py | 90 +++- .../scraper_kit/orchestration/scheduler.py | 45 ++ 6 files changed, 728 insertions(+), 22 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_scrape_skip_visibility.py diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py index d6d68bf2..df1763d0 100644 --- a/tradein-mvp/backend/app/services/cian_session.py +++ b/tradein-mvp/backend/app/services/cian_session.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import logging +from datetime import datetime from typing import Any from curl_cffi.requests import AsyncSession @@ -23,6 +24,11 @@ from app.core.config import settings logger = logging.getLogger(__name__) +# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ +# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания +# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое. +COOKIE_EXPIRY_WARN_DAYS = 5 + # Cookies критичные для Cian auth — фильтр перед сохранением. # Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23). # Старые записи оставлены как fallback (backward compat). @@ -294,6 +300,29 @@ def load_session(db: Session) -> dict[str, str] | None: return cookies +def session_expires_at(db: Session) -> datetime | None: + """Когда протухают самые свежезагруженные куки — БЕЗ фильтра валидности (#2658). + + `load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на + протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от + «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. Здесь фильтра нет: None означает + ровно «записей нет вовсе». + """ + row = db.execute( + text( + """ + SELECT expires_at_estimate FROM cian_session_cookies + ORDER BY uploaded_at DESC + LIMIT 1 + """ + ) + ).first() + if row is None: + return None + expires_at: datetime | None = row[0] + return expires_at + + def mark_session_invalid(db: Session, account_user_id: int) -> None: """Flag session как expired/invalid (например после 401 во время scrape).""" db.execute( diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index b31411da..b89ba26e 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -21,8 +21,10 @@ from __future__ import annotations import asyncio import logging +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any +from scraper_kit.orchestration import runs as kit_runs from scraper_kit.orchestration.scheduler import ( Handler, reschedule_after_minutes, @@ -39,39 +41,95 @@ logger = logging.getLogger(__name__) # ── cian_history_backfill — cookie-gated backfill ──────────────────────────── +# Машиночитаемые причины пропуска (#2658) — пишутся в scrape_runs.error строки +# со status='skipped'. Отделены от kit-причин (already_running и т.п.): по слагу +# видно, встал ли сбор из-за кук или из-за конкурентного прогона. +SKIP_CIAN_COOKIES_MISSING = "cian_cookies_missing" +SKIP_CIAN_COOKIES_EXPIRED = "cian_cookies_expired" +SKIP_CIAN_COOKIES_INVALID = "cian_cookies_invalid" + + +def _alert_cian_cookies(source: str, detail: str) -> None: + """Громкий алерт «сбор встал из-за кук» — logger.error, НЕ capture_message(warning). + + В scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR) + (scheduler_main.py) — ERROR-запись сама становится событием, а прежний + `capture_message(..., level="warning")` до этого уровня не дотягивал (и стоял в + недостижимой ветке, см. докстринг _cian_pre_claim). Заодно причина остаётся в + docker-логах и в строке scrape_runs, которая переживает редеплой. + """ + logger.error( + "scheduler: %s пропущен — %s. Перезалейте куки Циана через админку " + "(до этого backfill истории стоит)", + source, + detail, + ) + + async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool: """Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522). - Cookies отсутствуют/протухли → defer next_run_at на следующее окно и skip - (иначе get_due_schedules переотбирает schedule каждые 60с и verify_session - долбит Cian круглосуточно). Дословно из боевого trigger_cian_backfill_run. - """ - import sentry_sdk + Cookies отсутствуют/протухли → пишем строку прогона status='skipped' с причиной, + двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает + schedule каждые 60с и verify_session долбит Cian круглосуточно). - from app.services.cian_session import load_session, verify_session + #2658 — что было не так. Первая ветка (load_session вернул None) молчала: warning в + docker-лог, сдвиг next_run_at, `return False`. Ни строки в scrape_runs, ни изменения + last_run_at — снаружи 37 дней простоя выглядели как «всё по расписанию». Sentry-алерт + стоял во ВТОРОЙ ветке (verify_session вернул None), до которой на протухших куках + исполнение не доходит НИКОГДА: load_session сам фильтрует expires_at_estimate > NOW() + и отдаёт None ещё в первой. Теперь громко в обеих + предупреждение ЗАРАНЕЕ, пока куки + ещё валидны (COOKIE_EXPIRY_WARN_DAYS) — обновление кук ручное, ему нужен запас. + """ + from app.services.cian_session import ( + COOKIE_EXPIRY_WARN_DAYS, + load_session, + session_expires_at, + verify_session, + ) + + source: str = schedule_row["source"] + now = datetime.now(tz=UTC) cookies = load_session(db) if cookies is None: - logger.warning("scheduler: cian_history_backfill skipped — no valid session cookies in DB") + expires_at = session_expires_at(db) + if expires_at is None: + reason, detail = SKIP_CIAN_COOKIES_MISSING, "кук Циана нет в БД" + elif expires_at <= now: + reason = SKIP_CIAN_COOKIES_EXPIRED + detail = ( + f"куки Циана протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)" + ) + else: + reason = SKIP_CIAN_COOKIES_INVALID + detail = "куки Циана помечены невалидными (last_invalid_at)" + _alert_cian_cookies(source, detail) + kit_runs.mark_skipped(db, source=source, reason=reason, details=detail) kit_defer_next_run_at(db, schedule_row) return False state = await verify_session(cookies) if state is None: - logger.warning( - "scheduler: cian_history_backfill — cookies expired or invalid, skipping run" - ) - try: - sentry_sdk.capture_message( - "cian_history_backfill skipped: Cian session cookies expired — " - "please re-upload via admin UI", - level="warning", - ) - except Exception: - pass # sentry_sdk not initialised in dev + # verify вернул именно None (401 / isAuthenticated=false) — куки числятся + # валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник + # недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session. + detail = "Циан не принимает куки (разлогин)" + _alert_cian_cookies(source, detail) + kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail) kit_defer_next_run_at(db, schedule_row) return False + # Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора. + expires_at = session_expires_at(db) + if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS): + logger.error( + "scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, " + "иначе %s встанет молча", + expires_at.date().isoformat(), + (expires_at - now).total_seconds() / 86400, + source, + ) return True diff --git a/tradein-mvp/backend/tests/test_scrape_skip_visibility.py b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py new file mode 100644 index 00000000..3e8caf4b --- /dev/null +++ b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py @@ -0,0 +1,480 @@ +"""Пропуск расписания оставляет след — строку scrape_runs(status='skipped') (#2658). + +До #2658 планировщик пропускал наступившее окно НЕМО: logger + сдвиг next_run_at, ни +строки прогона, ни изменения last_run_at. cian_history_backfill так простоял 37 дней на +протухших куках Циана, и снаружи это выглядело как «всё по расписанию». + +Покрываем: + 1. mark_skipped — INSERT новой строки и схлопывание подряд идущих одинаковых причин. + 2. Все ПЯТЬ мест, которые раньше пропускали молча: kit `_claim_run` ×3 (already_running / + concurrent_claim / running_appeared_under_lock), kit `scheduler_loop` (unknown_source) + и продуктовый cian `pre_claim`. + 3. Достижимость алерта: протухшие куки (load_session → None) дают ERROR-запись, которая + в scraper-контейнере становится событием GlitchTip (event_level=ERROR), — раньше + алерт стоял во второй ветке, куда на протухших куках исполнение не доходит. + 4. Неломание монитора нулевых прогонов (#2625): 'skipped' — не «прогон вернул ноль + лотов», обе alert-выборки его не видят и стрик им не прерывается. + +Без сети, без БД. +""" + +from __future__ import annotations + +import logging +import os +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import runs as kit_runs +from scraper_kit.orchestration import scheduler as kit_sched +from scraper_kit.orchestration.scheduler import ( + SKIP_ALREADY_RUNNING, + SKIP_CONCURRENT_CLAIM, + SKIP_RUNNING_UNDER_LOCK, + SKIP_UNKNOWN_SOURCE, + SchedulerContext, + _claim_run, +) + + +def _make_sched(source: str) -> dict[str, Any]: + return { + "id": 1, + "source": source, + "enabled": True, + "window_start_hour": 2, + "window_end_hour": 5, + "default_params": {}, + "last_run_id": None, + "last_run_at": None, + "next_run_at": None, + } + + +# ── 1. mark_skipped: INSERT + схлопывание ──────────────────────────────────── + + +class _FakeSkipDB: + """Session-мок для mark_skipped: UPDATE-ветка (схлопывание) vs INSERT-ветка.""" + + def __init__(self, *, collapse: bool) -> None: + self._collapse = collapse + self.statements: list[tuple[str, dict[str, Any]]] = [] + self.committed = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + sql = str(stmt) + self.statements.append((sql, params or {})) + result = MagicMock() + if "UPDATE scrape_runs r" in sql: + result.fetchone.return_value = MagicMock(id=7) if self._collapse else None + else: + result.fetchone.return_value = MagicMock(id=8) + return result + + def commit(self) -> None: + self.committed = True + + def sql_of(self, needle: str) -> tuple[str, dict[str, Any]] | None: + for sql, params in self.statements: + if needle in sql: + return sql, params + return None + + +def test_mark_skipped_inserts_row_with_machine_readable_reason() -> None: + db = _FakeSkipDB(collapse=False) + run_id = kit_runs.mark_skipped( + db, source="cian_history_backfill", reason="cian_cookies_expired", details="протухли" + ) + assert run_id == 8 + insert = db.sql_of("INSERT INTO scrape_runs") + assert insert is not None, "новая строка прогона не создана" + sql, params = insert + assert "'skipped'" in sql + assert params["source"] == "cian_history_backfill" + assert params["reason"] == "cian_cookies_expired" # слаг, не человеческий текст + assert "протухли" in params["counters"] + assert db.committed is True + + +def test_mark_skipped_collapses_consecutive_same_reason() -> None: + """Подряд идущие одинаковые пропуски не плодят строки — иначе тик 60с = строка/мин.""" + db = _FakeSkipDB(collapse=True) + run_id = kit_runs.mark_skipped(db, source="avito_full_load", reason=SKIP_ALREADY_RUNNING) + assert run_id == 7 + assert db.sql_of("INSERT INTO scrape_runs") is None, "схлопывание не сработало" + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, _params = update + assert "skips" in sql # счётчик повторов растёт вместо новой строки + + +# ── 2. пять мест: kit _claim_run ×3 ────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, *, scalar: Any = None, fetchone: Any = None) -> None: + self._scalar = scalar + self._fetchone = fetchone + + def scalar(self) -> Any: + return self._scalar + + def fetchone(self) -> Any: + return self._fetchone + + +class _FakeClaimDB: + """Мок Session для _claim_run (тот же сценарный контракт, что в parity-тестах).""" + + def __init__(self, *, running_states: list[bool], lock: bool = True) -> None: + self._running = list(running_states) + self._lock = lock + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt) + if "SELECT 1 FROM scrape_runs" in sql: + return _FakeResult(fetchone=(1,) if self._running.pop(0) else None) + if "pg_try_advisory_xact_lock" in sql: + return _FakeResult(scalar=self._lock) + return _FakeResult() + + def commit(self) -> None: + pass + + def rollback(self) -> None: + self.rolled_back = True + + +def _ctx() -> SchedulerContext: + runs = MagicMock() + runs.create_run = MagicMock(return_value=42) + return SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(), + runs=runs, + ) + + +@pytest.mark.parametrize( + ("running_states", "lock", "expected_reason"), + [ + ([True], True, SKIP_ALREADY_RUNNING), + ([False], False, SKIP_CONCURRENT_CLAIM), + ([False, True], True, SKIP_RUNNING_UNDER_LOCK), + ], +) +def test_claim_run_skip_writes_skipped_row( + running_states: list[bool], lock: bool, expected_reason: str +) -> None: + """Каждая из трёх skip-веток _claim_run пишет строку прогона с своей причиной.""" + db = _FakeClaimDB(running_states=running_states, lock=lock) + with patch.object(kit_runs, "mark_skipped") as mark: + run_id = _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + + assert run_id is None + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == expected_reason + assert mark.call_args.kwargs["source"] == "avito_city_sweep" + + +def test_claim_run_under_lock_rolls_back_before_writing() -> None: + """Строка пишется ПОСЛЕ rollback'а — иначе её INSERT улетел бы в откат лока.""" + db = _FakeClaimDB(running_states=[False, True], lock=True) + order: list[str] = [] + real_rollback = db.rollback + + def _rollback() -> None: + order.append("rollback") + real_rollback() + + db.rollback = _rollback # type: ignore[method-assign] + with patch.object(kit_runs, "mark_skipped", side_effect=lambda *a, **k: order.append("skip")): + _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + + assert order == ["rollback", "skip"] + + +def test_claim_run_happy_path_writes_no_skip_row() -> None: + """Успешный claim не должен оставлять skip-строк (иначе счётчики мусорные).""" + db = _FakeClaimDB(running_states=[False, False], lock=True) + with patch.object(kit_runs, "mark_skipped") as mark: + run_id = _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + assert run_id == 42 + mark.assert_not_called() + + +# ── 2b. пятое место: scheduler_loop с неизвестным source ───────────────────── + + +async def test_scheduler_loop_unknown_source_writes_skipped_row() -> None: + """enabled-расписание без handler'а: раньше — warning каждый тик и ноль следов.""" + ctx = SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(return_value=MagicMock()), + runs=MagicMock(), + # первый вызов (верх тика) — работаем, дальше — drain, чтобы выйти из while True + shutdown_requested=MagicMock(side_effect=[False, True, True]), + ) + with ( + patch.object(kit_sched.asyncio, "sleep", AsyncMock()), + patch.object(kit_sched, "reap_zombies", MagicMock(return_value=0)), + patch.object( + kit_sched, + "get_due_schedules", + MagicMock(return_value=[_make_sched("source_from_mars")]), + ), + patch.object(kit_runs, "mark_skipped") as mark, + ): + await kit_sched.scheduler_loop(ctx, registry={}) + + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == SKIP_UNKNOWN_SOURCE + assert mark.call_args.kwargs["source"] == "source_from_mars" + + +# ── 3. cian pre_claim: строка + достижимый алерт ───────────────────────────── + + +def _patch_cian( + *, + cookies: dict[str, str] | None, + expires_at: datetime | None, + verify: Any = None, +) -> Any: + from app.services import cian_session + + return ( + patch.object(cian_session, "load_session", MagicMock(return_value=cookies)), + patch.object(cian_session, "session_expires_at", MagicMock(return_value=expires_at)), + patch.object(cian_session, "verify_session", AsyncMock(return_value=verify)), + ) + + +async def test_cian_pre_claim_expired_cookies_logs_error() -> None: + """Фальсификация #2658-2: на протухших куках ДОЛЖНА быть ERROR-запись. + + Именно ERROR — в scraper-контейнере GlitchTip поднят с + LoggingIntegration(event_level=ERROR) (scheduler_main.py), warning событием не станет. + Старый код в этой ветке писал только logger.warning, а capture_message стоял во + второй ветке (verify_session → None), недостижимой при протухании: load_session сам + фильтрует expires_at_estimate > NOW(). + """ + from app.services import product_handlers + + expired = datetime.now(tz=UTC) - timedelta(days=37) + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=expired) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()), + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is False + errors = [r for r in records if r.levelno >= logging.ERROR] + assert errors, "протухание кук не породило ERROR-запись → события GlitchTip не будет" + + +async def test_cian_pre_claim_expired_cookies_writes_skipped_row() -> None: + """Немой `return False` заменён строкой прогона с причиной cian_cookies_expired.""" + from app.services import product_handlers + + expired = datetime.now(tz=UTC) - timedelta(days=37) + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=expired) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()) as defer, + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert proceed is False + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_EXPIRED + assert mark.call_args.kwargs["source"] == "cian_history_backfill" + defer.assert_called_once() # next_run_at по-прежнему двигаем (не долбим Циан каждые 60с) + + +async def test_cian_pre_claim_missing_cookies_reason_differs_from_expired() -> None: + """«Кук нет вовсе» и «протухли» — разные слаги: причина машиночитаема.""" + from app.services import product_handlers + + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=None) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_MISSING + assert product_handlers.SKIP_CIAN_COOKIES_MISSING != product_handlers.SKIP_CIAN_COOKIES_EXPIRED + + +async def test_cian_pre_claim_rejected_cookies_writes_skipped_row() -> None: + """Вторая ветка (Циан не принимает куки) — тоже строка, а не только алерт.""" + from app.services import product_handlers + + future = datetime.now(tz=UTC) + timedelta(days=20) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=future, verify=None + ) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert proceed is False + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_INVALID + + +async def test_cian_pre_claim_warns_before_expiry_not_after() -> None: + """Предупреждаем ЗАРАНЕЕ: куки ещё рабочие, но жить им меньше COOKIE_EXPIRY_WARN_DAYS. + + Обновление кук — ручная операция; алерт по факту протухания приходит, когда сбор уже + встал. Гейт при этом пропускает прогон (proceed=True) — предупреждение, не блокировка. + """ + from app.services import cian_session, product_handlers + + soon = datetime.now(tz=UTC) + timedelta(days=cian_session.COOKIE_EXPIRY_WARN_DAYS - 1) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=soon, verify={"user": {"isAuthenticated": True}} + ) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with p_load, p_exp, p_verify: + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is True + assert [r for r in records if r.levelno >= logging.ERROR], "не предупредили заранее" + + +async def test_cian_pre_claim_fresh_cookies_are_silent() -> None: + """Свежие куки — ни алерта, ни skip-строки (иначе алерт-усталость).""" + from app.services import product_handlers + + far = datetime.now(tz=UTC) + timedelta(days=25) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=far, verify={"user": {"isAuthenticated": True}} + ) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is True + mark.assert_not_called() + assert not [r for r in records if r.levelno >= logging.ERROR] + + +# ── 4. монитор нулевых прогонов не смешивается с пропусками ────────────────── + + +class _RecordingDB: + """Возвращает заданные строки на SELECT и запоминает SQL (для проверки фильтров).""" + + def __init__(self, rows: list[Any]) -> None: + self.rows = rows + self.sql: list[str] = [] + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + self.sql.append(str(stmt)) + result = MagicMock() + result.fetchall.return_value = self.rows + return result + + +def test_zero_result_monitor_ignores_skipped_rows() -> None: + """'skipped' НЕ участвует в стрике нулевых прогонов — это не «прогон вернул ноль». + + Смешать их в одном счётчике нельзя в обе стороны: пропуск не должен ни считаться + нулевым прогоном, ни прерывать стрик реальных нулевых. Отсечка делается в SQL — + проверяем, что 'skipped' не попал в список статусов выборки. + """ + db = _RecordingDB(rows=[]) + kit_runs._alert_if_consecutive_zero_results(db, "cian_city_sweep") + kit_runs._alert_if_consecutive_failures(db, "cian_city_sweep") + + assert db.sql, "alert-выборка не выполнилась" + for sql in db.sql: + assert "status IN ('failed', 'banned', 'done', 'cancelled')" in sql + assert "skipped" not in sql + + +def test_mark_skipped_status_is_not_a_failure_status() -> None: + """Строка-пропуск не попадает и в failed/banned-стрик (алерт «3 подряд ошибки»).""" + db = _FakeSkipDB(collapse=False) + kit_runs.mark_skipped(db, source="cian_history_backfill", reason="cian_cookies_expired") + insert = db.sql_of("INSERT INTO scrape_runs") + assert insert is not None + sql, _params = insert + assert "'skipped'" in sql + assert "'failed'" not in sql and "'banned'" not in sql diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index ca9f0aab..11a742f4 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -238,6 +238,8 @@ class _FakeClaimDB: self.committed = False self.rolled_back = False self.update_calls = 0 + # #2658: строки-пропуски (scrape_runs status='skipped'), которые пишет mark_skipped. + self.skip_rows = 0 def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) @@ -249,6 +251,13 @@ class _FakeClaimDB: if "UPDATE scrape_schedules" in sql: self.update_calls += 1 return _FakeResult() + # #2658 mark_skipped: сначала пробует схлопнуть последнюю skip-строку (UPDATE + # ... RETURNING), не нашёл — вставляет новую. + if "UPDATE scrape_runs r" in sql: + return _FakeResult(fetchone=None) + if "INSERT INTO scrape_runs" in sql: + self.skip_rows += 1 + return _FakeResult(fetchone=MagicMock(id=999)) return _FakeResult() def commit(self) -> None: @@ -286,6 +295,7 @@ def test_claim_run_skip_already_running() -> None: run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx) assert run_id is None ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658: пропуск оставляет строку, а не только лог def test_claim_run_skip_lock_busy() -> None: @@ -295,6 +305,7 @@ def test_claim_run_skip_lock_busy() -> None: run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx) assert run_id is None ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658 def test_claim_run_running_appeared_under_lock() -> None: @@ -305,6 +316,7 @@ def test_claim_run_running_appeared_under_lock() -> None: assert run_id is None assert db.rolled_back is True ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658 # ── 3. reap_zombies ────────────────────────────────────────────────────────── diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 8e921a0d..afa6c0a5 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -1,10 +1,14 @@ """scrape_runs helpers — tracking long-running pipeline runs (strangler-копия #2135). Байт-эквивалент `app.services.scrape_runs` — чистые SQL-хелперы поверх таблицы -`scrape_runs` (миграции 015 + 051). Развязка от `app.*`: единственное намеренное -отличие — `sentry_sdk` импортируется опционально (kit standalone-импортируем, а -`sentry-sdk` не входит в его зависимости). Если пакет не установлен — alert-хук -best-effort no-op, поведение SQL-финализаторов идентично старому. +`scrape_runs` (миграции 015 + 051). Развязка от `app.*`. Намеренные отличия от +app-копии: + 1. `sentry_sdk` импортируется опционально (kit standalone-импортируем, а + `sentry-sdk` не входит в его зависимости). Если пакет не установлен — alert-хук + best-effort no-op, поведение SQL-финализаторов идентично старому. + 2. `mark_skipped` (#2658) есть только здесь: строки-пропуски создаёт исключительно + планировщик (kit `_claim_run`/`scheduler_loop` + продуктовый cian pre_claim), + app-копии эта функция не нужна. """ from __future__ import annotations @@ -230,6 +234,84 @@ def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: return int(row.id) +def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = None) -> int: + """INSERT scrape_runs(status='skipped') — след пропущенного расписания (#2658). + + Раньше планировщик пропускал наступившее окно НЕМО: logger + сдвиг next_run_at, ни + строки прогона, ни изменения last_run_at — снаружи «всё по расписанию» + (cian_history_backfill так простоял 37 дней на протухших куках). Статус 'skipped' + заведён ещё миграцией 015 и локализован во фронте («пропущено»), но до #2658 не + использовался ни разу. + + `reason` — машиночитаемый слаг (already_running / cian_cookies_expired / …), пишется + в `error`: по нему пропуск «нет кук» отличается от «уже бежит» без разбора текста. + `details` — человеческое пояснение, кладётся в `counters.detail`. + + Схлопывание подряд идущих одинаковых пропусков: если ПОСЛЕДНЯЯ строка прогона этого + source — уже 'skipped' с тем же reason, новая не создаётся; у существующей + обновляется finished_at и счётчик `counters.skips`. Без этого «уже бежит» и + «неизвестный source» плодили бы строку каждый тик (60 с), пока держится причина. + + Возвращает id строки (новой или обновлённой). + """ + row = db.execute( + text( + """ + WITH latest AS ( + SELECT id FROM scrape_runs + WHERE source = :source + ORDER BY id DESC + LIMIT 1 + ) + UPDATE scrape_runs r + SET heartbeat_at = NOW(), + finished_at = NOW(), + counters = jsonb_set( + COALESCE(r.counters, '{}'::jsonb), + '{skips}', + to_jsonb(COALESCE(CAST(r.counters ->> 'skips' AS int), 0) + 1) + ) + FROM latest + WHERE r.id = latest.id + AND r.status = 'skipped' + AND r.error = :reason + RETURNING r.id + """ + ), + {"source": source, "reason": reason}, + ).fetchone() + + if row is None: + row = db.execute( + text( + """ + INSERT INTO scrape_runs ( + source, status, error, counters, started_at, heartbeat_at, finished_at + ) + VALUES ( + :source, 'skipped', :reason, CAST(:counters AS jsonb), NOW(), NOW(), NOW() + ) + RETURNING id + """ + ), + { + "source": source, + "reason": reason, + "counters": json.dumps({"skips": 1, "detail": details}, ensure_ascii=False), + }, + ).fetchone() + + db.commit() + assert row is not None, "scrape_runs skipped-row INSERT returned no id" + logger.warning( + "scheduler: skipped run source=%s reason=%s%s", + source, + reason, + f" ({details})" if details else "", + ) + return int(row.id) + + def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None: """UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки. diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index a88976f3..ccef889a 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -69,6 +69,15 @@ ZOMBIE_THRESHOLD_HOURS = 6 # (см. #1182 P2 — идентично боевому scheduler'у). _CHILD_DRAIN_TIMEOUT_S = 80.0 +# Машиночитаемые причины пропуска расписания (#2658) — пишутся в scrape_runs.error +# строки со status='skipped'. Слаг, а не человеческий текст: по нему «уже бежит» +# отличается от «нет кук» (продуктовые причины — в app.services.product_handlers) +# запросом, а не грепом логов. +SKIP_ALREADY_RUNNING = "already_running" +SKIP_CONCURRENT_CLAIM = "concurrent_claim" +SKIP_RUNNING_UNDER_LOCK = "running_appeared_under_lock" +SKIP_UNKNOWN_SOURCE = "unknown_source" + # ── типы job/handler ───────────────────────────────────────────────────────── # Job получает свежую сессию (открыта `_dispatch`), run_id, params и весь контекст # (config/matcher/enrichment/runs) — чтобы иметь доступ к инжектированным зависимостям. @@ -254,10 +263,21 @@ def _claim_run(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) Returns run_id или None если уже есть running run для этого source. Общий helper для всех source-handler'ов. Advisory-lock concurrency-логика перенесена ДОСЛОВНО из боевого scheduler'а (создание run — через инжектированный ctx.runs). + + #2658: каждая ветка «пропустили окно» пишет строку scrape_runs(status='skipped') + с машиночитаемой причиной — раньше пропуск был виден только в docker-логах, которые + теряются при редеплое. Пишем через kit-копию runs (у инжектированной app-копии + mark_skipped нет — строки-пропуски создаёт только планировщик). """ source = schedule_row["source"] if has_running_run(db, source): logger.info("scheduler: skip — already running for source=%s", source) + _kit_runs.mark_skipped( + db, + source=source, + reason=SKIP_ALREADY_RUNNING, + details="предыдущий прогон ещё идёт", + ) return None # #750: атомарный claim через transaction-scoped advisory lock. Сериализует @@ -272,6 +292,12 @@ def _claim_run(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) ).scalar() if not got_lock: logger.info("scheduler: skip — concurrent claim in progress for source=%s", source) + _kit_runs.mark_skipped( + db, + source=source, + reason=SKIP_CONCURRENT_CLAIM, + details="конкурентный тик уже клеймит этот source", + ) return None # Double-checked под локом: конкурентный тик мог закоммитить running-run МЕЖДУ @@ -280,6 +306,13 @@ def _claim_run(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) if has_running_run(db, source): logger.info("scheduler: skip — running appeared under lock for source=%s", source) db.rollback() # освобождаем advisory lock (claim не состоялся) + # mark_skipped ПОСЛЕ rollback'а: его INSERT + commit иначе улетели бы в откат. + _kit_runs.mark_skipped( + db, + source=source, + reason=SKIP_RUNNING_UNDER_LOCK, + details="running-прогон появился под локом", + ) return None params = schedule_row.get("default_params") or {} @@ -700,6 +733,14 @@ async def scheduler_loop(ctx: SchedulerContext, registry: Mapping[str, Handler]) handler = resolve_handler(source, registry) if handler is None: logger.warning("scheduler: unknown source=%s, skip", source) + # #2658: enabled-расписание без handler'а молча не выполнялось + # бы вечно (next_run_at не двигается → warning каждый тик). + _kit_runs.mark_skipped( + db, + source=source, + reason=SKIP_UNKNOWN_SOURCE, + details="нет handler'а в реестре", + ) else: await _dispatch(handler, db, sch, ctx) @@ -730,6 +771,10 @@ async def scheduler_loop(ctx: SchedulerContext, registry: Mapping[str, Handler]) __all__ = [ "SCHEDULER_TICK_SEC", + "SKIP_ALREADY_RUNNING", + "SKIP_CONCURRENT_CLAIM", + "SKIP_RUNNING_UNDER_LOCK", + "SKIP_UNKNOWN_SOURCE", "ZOMBIE_THRESHOLD_HOURS", "Handler", "SchedulerContext", From 7d154de1f794b2cd567d6f20aab4375c02061bfe Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 22:38:23 +0500 Subject: [PATCH 003/130] =?UTF-8?q?feat(tradein/auth):=20=D0=B3=D0=BB?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D0=BE=D0=BA=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=BC=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20(#2571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лимит на логине ключевался парой (username, IP), поэтому распределённый перебор одного имени с тысячи адресов получал по 5 попыток с каждого источника и не упирался ни во что. После снятия Caddy basic_auth с /trade-in (#2558) POST /auth/login — единственная ручка, доступная из интернета без кредов, так что дыра открыта прямо сейчас. Поверх существующего per-IP лимита добавлен глобальный счётчик неудач на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы вектором отказа в обслуживании против конкретного человека — не зная пароля, злоумышленник гарантированно выключал бы чужой вход. Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый 401» для несуществующего имени стал бы оракулом существования учётки, то есть ровно той user-enumeration, от которой уже защищают одинаковый generic-ответ и безусловный bcrypt. --- tradein-mvp/backend/app/api/v1/auth.py | 123 +++++++++-- tradein-mvp/backend/app/core/config.py | 21 ++ tradein-mvp/backend/app/core/ratelimit.py | 10 +- tradein-mvp/backend/tests/test_auth_api.py | 225 +++++++++++++++++++++ 4 files changed, 356 insertions(+), 23 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index a839d936..bd3b3ec8 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -28,12 +28,18 @@ Security: username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip) парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель в username недостаточно — паразитная граница возможна с обеих сторон). + - Поверх него — ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571): + лимит по паре (username, IP) распределённый перебор обходит целиком, просто + меняя адрес. Превышение порога не блокирует вход, а замедляет ответ + (`_throttle_delay_s`) — см. развёрнутое обоснование там же. - Raw-пароль НИКОГДА не логируется и не попадает в user_events payload — - только username/ip/user_agent/path/method (см. schedule_event ниже). + только username/ip/user_agent/path/method и (для неудач) состояние + счётчика попыток: сколько их за окно и какая задержка применена. """ from __future__ import annotations +import asyncio import logging import secrets from typing import Annotated @@ -63,6 +69,25 @@ _LOGIN_LIMITER = SlidingWindowLimiter( window_s=settings.login_rate_limit_window_s, ) +# Глобальный счётчик неудач НА ИМЯ (#2571) — ключ БЕЗ IP, поэтому попытки со +# всех адресов складываются в один бюджет. Дополняет `_LOGIN_LIMITER`, а не +# заменяет: тот режет частый перебор с одного адреса, этот — редкий, но с +# тысячи адресов (credential stuffing), от которого per-(username, IP) ключ не +# защищает вообще — каждый новый адрес получает свежие login_rate_limit попыток. +# +# Живёт В ПАМЯТИ ПРОЦЕССА — сознательно, а не по недосмотру. Прод-бэкенд +# запущен одним uvicorn-воркером (docker-compose.prod.yml, комментарий над +# `command`: «Single worker сохраняется для предсказуемости»), значит счётчик и +# так глобален, а Redis в auth-пути добавил бы сетевую зависимость там, где её +# падение = либо дыра (fail-open), либо отказ входа (fail-closed). +# Потолок: появятся воркеры (`--workers N`) — потолок делится на N, и его надо +# переносить в Redis (`app.services.cache` уже держит там пул). Тот же ceiling +# у соседнего `_LOGIN_LIMITER`; перезапуск процесса обнуляет оба. +_USERNAME_FAIL_LIMITER = SlidingWindowLimiter( + limit=settings.login_username_fail_threshold, + window_s=settings.login_username_fail_window_s, +) + # Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля, # сгенерированный ОДИН РАЗ на импорте модуля — используется вместо # password_hash, когда юзер не найден/деактивирован/без пароля, чтобы @@ -92,6 +117,78 @@ class LoginResponse(BaseModel): ok: bool = True +def _throttle_delay_s(fails_in_window: int) -> float: + """Насколько задержать ответ на неудачный вход при *fails_in_window* неудачах + по этому имени за окно. 0 — пока порог не перебран. + + Замедление, а НЕ блокировка — намеренно. Жёсткая блокировка учётки после N + неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он + гарантированно выключает вход конкретному человеку (директору, админу) — + отказ в обслуживании дешевле и надёжнее, чем то, от чего блокировка + защищает. Задержка же не отнимает доступ ни у кого: владелец пароля войдёт + с первой попытки, просто ответ на очередную НЕУДАЧУ придёт медленнее. + + Рост удвоением от 1с с потолком `login_username_throttle_max_delay_s`: + первые перебранные попытки почти незаметны, а сотни — упираются в потолок. + Потолок обязателен: без него задержка становится той же блокировкой, только + растянутой во времени. + """ + excess = fails_in_window - settings.login_username_fail_threshold + if excess <= 0: + return 0.0 + return min(settings.login_username_throttle_max_delay_s, float(2 ** (excess - 1))) + + +async def _reject_invalid_credentials( + username: str, ip: str, user_agent: str | None +) -> HTTPException: + """Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 401. + + Один код на все ветки отказа (нет такого имени / неверный пароль / доступ + закрыт / password_hash NULL) — это не борьба с дублированием, а инвариант: + ветки обязаны быть неразличимы снаружи. Разъедься они по телу хендлера — + и достаточно забыть задержку в одной, чтобы «быстрый 401» стал оракулом + существования учётки ровно в том же виде, что и разные сообщения об ошибке. + Поэтому счётчик ведётся по ПРИСЛАННОМУ имени, без проверки, есть ли такое + в реестре: несуществующее имя копит неудачи и тормозит так же, как живое. + (`get_user_by_username` сверяет `username = :username` по text-колонке без + нормализации, так что сырое имя — тот же ключ, что и у поиска: регистром + счётчик не обойти.) + + Возвращает `HTTPException`, а не бросает: `raise await …` не собирается, а + `raise (await …)` читается хуже, чем `raise` над возвращённым значением. + """ + fails = _USERNAME_FAIL_LIMITER.record(username) + delay_s = _throttle_delay_s(fails) + + schedule_event( + event_type="login_failed", + username=username, + ip=ip, + user_agent=user_agent, + path="/api/v1/auth/login", + method="POST", + # Состояние глобального счётчика — в аудит: по нему в user_events видно + # именно РАСПРЕДЕЛЁННЫЙ перебор (десятки неудач по одному имени с разных + # ip_address), который иначе выглядит как россыпь одиночных неудач. + payload={"username_fails_in_window": fails, "throttle_delay_s": delay_s}, + ) + + if delay_s > 0: + logger.warning( + "login throttle: username=%r fails=%d delay=%.1fs ip=%s", + username, + fails, + delay_s, + ip, + ) + # await, не time.sleep: событийный цикл в это время обслуживает всех + # остальных — тормозим перебор, а не сервис. + await asyncio.sleep(delay_s) + + return HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL) + + @router.post("/login", response_model=LoginResponse) async def login( body: LoginRequest, @@ -124,15 +221,7 @@ async def login( # Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние # доступа. Порядок несущий, а не стилистический: см. модульный docstring. if user is None or not password_ok: - schedule_event( - event_type="login_failed", - username=body.username, - ip=ip, - user_agent=user_agent, - path="/api/v1/auth/login", - method="POST", - ) - raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL) + raise await _reject_invalid_credentials(body.username, ip, user_agent) access_state = user["access_state"] if access_state is AccessState.TRIAL_EXPIRED: @@ -156,17 +245,9 @@ async def login( if not access_state.can_sign_in: # disabled (и любое нераспознанное состояние — to_access_state fail-closed) - # → ТОТ ЖЕ generic 401 и то же событие, что при неверном пароле: - # заблокированный аккаунт неотличим от несуществующего. - schedule_event( - event_type="login_failed", - username=body.username, - ip=ip, - user_agent=user_agent, - path="/api/v1/auth/login", - method="POST", - ) - raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL) + # → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном + # пароле: заблокированный аккаунт неотличим от несуществующего. + raise await _reject_invalid_credentials(body.username, ip, user_agent) token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index e8d888ca..60ee2d8e 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -95,6 +95,27 @@ class Settings(BaseSettings): login_rate_limit_window_s: int = Field( default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S" ) + # Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571). + # Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого + # нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО + # имя, поэтому попытки со всех адресов складываются. + # + # Превышение порога НЕ блокирует учётку (это был бы вектор DoS против + # конкретного человека — злоумышленник выключал бы чужой вход по своему + # желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час + # выбран так, чтобы живой человек с опечатками до него не доходил. + login_username_fail_threshold: int = Field( + default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD" + ) + login_username_fail_window_s: int = Field( + default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S" + ) + # Потолок задержки одного ответа. Держим невысоким сознательно: задержка — + # это ещё и цена, которую платит легитимный владелец имени, пока его + # перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг». + login_username_throttle_max_delay_s: float = Field( + default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S" + ) # ── Эпик «единый вход»: общий реестр людей в БД `auth` ───────────────────── # DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index 2b809ddb..f5f3fe04 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -114,8 +114,13 @@ class SlidingWindowLimiter: return self._window_s - (now - bucket[0]) return None - def record(self, key: str) -> None: - """Регистрирует одну успешную попытку под *key*.""" + def record(self, key: str) -> int: + """Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё. + + Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login + (#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа + пропорционально. Значение можно игнорировать — `check()` так и делает. + """ now = time.monotonic() bucket = self._hits[key] self._prune(bucket, now) @@ -125,6 +130,7 @@ class SlidingWindowLimiter: if len(self._hits) > 10000: for k in [k for k, v in self._hits.items() if not v]: del self._hits[k] + return len(bucket) def check(self, key: str) -> float | None: """Комбинированная проверка+регистрация (peek+record за один вызов) — diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 6f3d3da1..48e9c406 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -33,6 +33,7 @@ from __future__ import annotations import os import re +import time from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Annotated, Any @@ -254,6 +255,7 @@ def _build_test_app(store: _Store) -> FastAPI: def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: auth_mod.reset_cache_for_tests() auth_router._LOGIN_LIMITER._hits.clear() + auth_router._USERNAME_FAIL_LIMITER._hits.clear() monkeypatch.setattr(config.settings, "auth_mode", "dual") # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже # если предыдущий переключался на `auth`. @@ -405,6 +407,229 @@ def test_login_rate_limit_429(client: TestClient, store: _Store) -> None: assert "Retry-After" in resp.headers +# --------------------------------------------------------------------------- +# #2571 — глобальный потолок попыток НА ИМЯ (распределённый перебор) +# --------------------------------------------------------------------------- + + +def _throttle_settings( + monkeypatch: pytest.MonkeyPatch, *, threshold: int, max_delay_s: float +) -> None: + """Порог и потолок задержки под тест: реальные 20/час и 8с ждать некогда.""" + monkeypatch.setattr(config.settings, "login_username_fail_threshold", threshold) + monkeypatch.setattr(config.settings, "login_username_throttle_max_delay_s", max_delay_s) + + +def _capture_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw)) + return events + + +def _failed_login_delays(events: list[dict[str, Any]]) -> list[float]: + return [e["payload"]["throttle_delay_s"] for e in events if e["event_type"] == "login_failed"] + + +def test_throttle_delay_grows_and_caps(monkeypatch: pytest.MonkeyPatch) -> None: + """Задержка = 0 до порога, дальше удваивается от 1с и упирается в потолок. + + Потолок обязателен: без него растущая задержка вырождается в ту самую + блокировку учётки, которой #2571 сознательно избегает (DoS против человека). + """ + _throttle_settings(monkeypatch, threshold=3, max_delay_s=4.0) + + assert auth_router._throttle_delay_s(1) == 0.0 + assert auth_router._throttle_delay_s(3) == 0.0 # ровно порог — ещё не перебор + assert auth_router._throttle_delay_s(4) == 1.0 + assert auth_router._throttle_delay_s(5) == 2.0 + assert auth_router._throttle_delay_s(6) == 4.0 + assert auth_router._throttle_delay_s(7) == 4.0 # потолок + assert auth_router._throttle_delay_s(1000) == 4.0 + + +def test_distributed_bruteforce_one_username_many_ips_hits_global_ceiling( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 1: перебор одного имени с РАЗНЫХ адресов упирается в потолок на имя. + + Каждая попытка приходит со своего IP, поэтому per-(username, IP) лимит + (`_LOGIN_LIMITER`, 5/300с) не срабатывает НИ РАЗУ — ровно та дыра, из-за + которой issue заведена. Ловит её только глобальный счётчик на имя. + """ + store.add_user("victim", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=3, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + for i in range(6): + resp = client.post( + "/api/v1/auth/login", + json={"username": "victim", "password": f"guess{i}"}, + headers={"x-forwarded-for": f"10.0.0.{i}"}, + ) + # per-IP бюджет не исчерпан ни на одном адресе — никаких 429. + assert resp.status_code == 401, resp.text + + delays = _failed_login_delays(events) + assert len(delays) == 6 + # Первые три — в пределах порога, дальше замедление. + assert delays[:3] == [0.0, 0.0, 0.0] + assert all(d > 0 for d in delays[3:]) + # Счётчик глобальный: считает попытки со ВСЕХ адресов, а не по каждому свой. + fails = [e["payload"]["username_fails_in_window"] for e in events] + assert fails == [1, 2, 3, 4, 5, 6] + + +def test_throttle_actually_delays_the_response( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Задержка именно ЖДЁТСЯ, а не только пишется в аудит. + + Отдельный тест, потому что все остальные читают величину задержки из + события: без этой проверки реализация, которая посчитала задержку и забыла + `await`, оставила бы весь набор зелёным. + """ + store.add_user("slowpoke", hash_password("Secret123!"), role="employee") + # threshold=0 → тормозим уже первую неудачу; потолок 1с — цена одного теста. + _throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0) + + started = time.monotonic() + resp = client.post("/api/v1/auth/login", json={"username": "slowpoke", "password": "wrong"}) + elapsed = time.monotonic() - started + + assert resp.status_code == 401 + assert elapsed >= 1.0 + + +def test_typo_does_not_throttle_and_correct_password_still_works( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 2: живой человек с опечаткой не наказан. + + Порог штатный (20/час), три опечатки до него не дотягивают — задержки нет, + а верный пароль пускает сразу же. Замедление применяется ТОЛЬКО к неудаче, + поэтому даже перебранный порог не мешает владельцу пароля войти. + """ + store.add_user("erin", hash_password("Secret123!"), role="employee") + events = _capture_events(monkeypatch) + + for _ in range(3): + resp = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123"}) + assert resp.status_code == 401 + + assert _failed_login_delays(events) == [0.0, 0.0, 0.0] + + ok = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123!"}) + assert ok.status_code == 200, ok.text + assert config.settings.session_cookie_name in ok.cookies + + +def test_throttle_identical_for_existing_and_unknown_username( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Замедление не должно стать оракулом существования учётки. + + Если тормозить только существующие имена, злоумышленник перечисляет живые + логины по времени ответа, не зная ни одного пароля — та же user-enumeration, + от которой защищают одинаковый 401 и безусловный bcrypt (`_DUMMY_PASSWORD_HASH`), + только по другому каналу. Счётчик ведётся по присланному имени БЕЗ проверки + в реестре, поэтому обе последовательности обязаны совпасть. + """ + store.add_user("real", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=2, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + real_responses = [] + for i in range(4): + real_responses.append( + client.post( + "/api/v1/auth/login", + json={"username": "real", "password": "wrong"}, + headers={"x-forwarded-for": f"10.1.0.{i}"}, + ) + ) + real_delays = _failed_login_delays(events) + + events.clear() + ghost_responses = [] + for i in range(4): + ghost_responses.append( + client.post( + "/api/v1/auth/login", + json={"username": "ghost", "password": "wrong"}, + headers={"x-forwarded-for": f"10.2.0.{i}"}, + ) + ) + ghost_delays = _failed_login_delays(events) + + assert real_delays == ghost_delays + assert real_delays[:2] == [0.0, 0.0] and all(d > 0 for d in real_delays[2:]) + # И «наблюдаемая» часть ответа тоже неотличима. + assert [r.status_code for r in real_responses] == [r.status_code for r in ghost_responses] + assert {r.json()["detail"] for r in real_responses} == { + r.json()["detail"] for r in ghost_responses + } + + +def test_disabled_account_throttled_like_wrong_password( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Отказ по «доступ закрыт» идёт тем же хвостом: счётчик, аудит, задержка. + + Иначе быстрый 401 у заблокированной учётки отличал бы её от несуществующей. + """ + store.add_user( + "blocked", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED + ) + _throttle_settings(monkeypatch, threshold=1, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + for i in range(2): + # Пароль ВЕРНЫЙ — отказ приходит именно из ветки состояния доступа. + resp = client.post( + "/api/v1/auth/login", + json={"username": "blocked", "password": "Secret123!"}, + headers={"x-forwarded-for": f"10.3.0.{i}"}, + ) + assert resp.status_code == 401 + + assert _failed_login_delays(events) == [0.0, pytest.approx(0.01)] + + +def test_failed_login_events_reach_audit_with_counter_state( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 3: неудачные входы видны в аудите — с ip/ua и состоянием счётчика. + + Именно счётчик отличает распределённый перебор от россыпи случайных + одиночных неудач: в `user_events` иначе видно только «по одной неудаче с + каждого адреса», что само по себе не тревожно. + """ + store.add_user("audited", hash_password("Secret123!"), role="employee") + events = _capture_events(monkeypatch) + + resp = client.post( + "/api/v1/auth/login", + json={"username": "audited", "password": "s3cret-typo"}, + # Левый хоп подделан клиентом, правый добавлен Caddy — в аудит обязан + # попасть ПРАВЫЙ (`ratelimit._client_ip`, допущение «ровно один прокси»). + # Иначе злоумышленник переписывал бы себе ip в журнале одним заголовком. + headers={ + "x-forwarded-for": "198.51.100.9, 203.0.113.7", + "user-agent": "probe/1.0", + }, + ) + + assert resp.status_code == 401 + (failed,) = (e for e in events if e["event_type"] == "login_failed") + assert failed["username"] == "audited" + assert failed["ip"] == "203.0.113.7" + assert failed["user_agent"] == "probe/1.0" + assert failed["path"] == "/api/v1/auth/login" + assert failed["payload"]["username_fails_in_window"] == 1 + # Raw-пароль не утекает ни в поля события, ни в payload. + assert "s3cret-typo" not in str(failed) + + # --------------------------------------------------------------------------- # POST /logout # --------------------------------------------------------------------------- From 837ad8cfd41b6a374a488fce1f8c7700cf8d8f32 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 22:39:48 +0500 Subject: [PATCH 004/130] =?UTF-8?q?fix(tradein):=20=D1=81=D0=B5=D0=B3?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B3=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=20=D0=B2=20=C2=AB=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=20=D1=82=D0=BE=D1=80=D0=B3=D0=B5=C2=BB,=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D0=B6=D0=B5=D1=81=D1=82=D1=8C=20=D0=B2=20?= =?UTF-8?q?=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B5=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD-=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20(#2660)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пользовательская половина разбора #2574: витрины читают listings без сегмента и без свежести, поэтому показывают числа, посчитанные не по тому пулу. 1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings(). 27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и девелоперский прайс (который не торгуется) формировал показываемый процент торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция намеренно смотрит и снятые объявления, иначе к сделке нечего подставить. Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205). 2. location_index — предикат свежести + сегментный гард в обоих запросах медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на тот же пул, на котором считается цена; окно свежести берётся импортом LISTINGS_FRESH_DAYS, второго определения константы не заводим. 3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем: рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе). Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть как «всё собирается». Refs #2660 --- tradein-mvp/backend/app/api/v1/admin.py | 26 ++- tradein-mvp/backend/app/api/v1/trade_in.py | 19 +- .../backend/app/services/location_index.py | 31 ++- .../211_sales_vs_listings_segment_guard.sql | 204 +++++++++++++++++ .../tests/services/test_location_index.py | 48 ++++ .../tests/test_admin_stale_counters.py | 205 +++++++++++++++++ ...ion_211_sales_vs_listings_segment_guard.py | 207 ++++++++++++++++++ .../backend/tests/test_scraper_admin_apis.py | 7 +- 8 files changed, 741 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql create mode 100644 tradein-mvp/backend/tests/test_admin_stale_counters.py create mode 100644 tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 4a803c64..49a5c6b4 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -72,6 +72,7 @@ from app.services import cian_session as cian_session_svc from app.services import domclick_session as domclick_session_svc from app.services import proxy_rotation as proxy_rotation_svc from app.services import scrape_runs as runs_mod +from app.services.estimator import LISTINGS_FRESH_DAYS from app.services.geocoder import geocode, known_city_hint from app.services.proxy_pool import clear_source_bans from app.services.scheduler import has_running_run @@ -2473,6 +2474,12 @@ async def update_scraper_pacing( class SourceCoverage(BaseModel): source: str active_count: int + # #2660: «активно» ≠ «живо». is_active снимается только деактиватором протухших, + # а он покрывает не все источники — на проде (2026-08-05) cian показывал 18 530 + # активных при 12 683 не виденных 14+ дней. Из-за этого #2574 месяц читалась как + # «всё собирается». Не прячем протухшее из счётчика, а отдаём ВТОРЫМ числом + # рядом — тогда «активно» перестаёт читаться как «живо». + stale_count: int fields: dict[str, float] # field_name -> fill% (0..100, round 1) @@ -2487,6 +2494,9 @@ class HousesCoverage(BaseModel): class DataQualityResponse(BaseModel): sources: list[SourceCoverage] houses: HousesCoverage + # Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI + # подписывал число, а не хардкодил порог у себя вторым определением. + stale_days: int # Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL). @@ -2516,6 +2526,10 @@ def get_data_quality( living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations. houses: total, avito_validated_at%, rating_score%, house_type%. house_reviews: общий count. + + #2660: рядом с active_count отдаётся stale_count — сколько из «активных» не + виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе + (stale_days), чтобы UI не заводил второе определение. """ # Строим single-pass SELECT для listings полей через FILTER-агрегаты. # Структура: COUNT(*) FILTER (WHERE ) / NULLIF(COUNT(*), 0) * 100 @@ -2523,10 +2537,17 @@ def get_data_quality( filter_exprs = ", ".join( f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS ) + # last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не + # виделись». На проде две колонки не расходятся (замер 2026-08-05: 0 активных + # строк с разницей ≥ суток), но семантика счётчика — про «видели», и колонка + # должна называть ровно её. sql_listings = text(f""" SELECT source, COUNT(*) AS active_count, + COUNT(*) FILTER ( + WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval + ) AS stale_count, {filter_exprs} FROM listings WHERE is_active = true @@ -2534,7 +2555,7 @@ def get_data_quality( ORDER BY source """) - rows = db.execute(sql_listings).mappings().all() + rows = db.execute(sql_listings, {"fresh_days": LISTINGS_FRESH_DAYS}).mappings().all() sources: list[SourceCoverage] = [] for row in rows: @@ -2548,6 +2569,7 @@ def get_data_quality( SourceCoverage( source=row["source"], active_count=int(row["active_count"]), + stale_count=int(row["stale_count"] or 0), fields=fields, ) ) @@ -2575,7 +2597,7 @@ def get_data_quality( reviews_count=reviews_count, ) - return DataQualityResponse(sources=sources, houses=houses) + return DataQualityResponse(sources=sources, houses=houses, stale_days=LISTINGS_FRESH_DAYS) # ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ─────────────────── diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 3bfa7e73..12e25b24 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -750,7 +750,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: trade_in_estimates с непустым address; NULL при отсутствии адресов. NB: это честный best-effort по persisted оценкам, а не hit-rate реального кэша (отдельного счётчика попаданий не ведём). + + #2660: listings_active сам по себе врал — «активно» на проде не означает + «живо» (деактиватор протухших покрывает не все источники). Рядом отдаём + listings_active_stale — сколько из них не виделись listings_stale_days + (= LISTINGS_FRESH_DAYS эстиматора; прод 2026-08-05: 37 900 активных при + 20 935 не виденных 14+ дней). Счётчик не прячем, а разделяем. """ + from app.services.estimator import LISTINGS_FRESH_DAYS + row = ( db.execute( text( @@ -760,6 +768,10 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: (SELECT count(*) FROM geocode_cache WHERE expires_at > NOW()) AS geocode_cache_fresh, (SELECT count(*) FROM listings WHERE is_active) AS listings_active, + (SELECT count(*) FROM listings + WHERE is_active + AND last_seen_at <= NOW() - (:fresh_days || ' days')::interval) + AS listings_active_stale, (SELECT max(scraped_at) FROM listings) AS listings_last_scraped, (SELECT count(*) FROM deals) AS deals, (SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings, @@ -776,12 +788,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: WHERE address IS NOT NULL AND address <> '' ) t) AS repeat_address_pct """ - ) + ), + {"fresh_days": LISTINGS_FRESH_DAYS}, ) .mappings() .fetchone() ) - return dict(row) if row else {} + # Порог отдаём рядом с числом — чтобы UI подписывал «не виделись N дней», + # а не заводил второе определение свежести у себя. + return (dict(row) | {"listings_stale_days": LISTINGS_FRESH_DAYS}) if row else {} # ── Stage 4a: house info + IMV benchmark для UI ─────────────────────────────── diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py index 7b38d30e..8e437b9d 100644 --- a/tradein-mvp/backend/app/services/location_index.py +++ b/tradein-mvp/backend/app/services/location_index.py @@ -13,9 +13,13 @@ POI-score его не улавливал (POI ranking ≠ цена). НОВЫЙ ПОКАЗАТЕЛЬ (location index): - location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки − + location_index_pct = (медиана ₽/м² сопоставимых листингов в радиусе точки − медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100 +«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие +(scraped_at в пределах LISTINGS_FRESH_DAYS — `is_active` на проде не равно «живо») И +только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы). + Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую ("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) — медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат @@ -45,6 +49,11 @@ from typing import Any from pydantic import BaseModel from sqlalchemy import text +# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте. +# Дублировать значение здесь нельзя: две константы разъедутся при первой же +# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена. +from app.services.estimator import LISTINGS_FRESH_DAYS + logger = logging.getLogger(__name__) # ── Гео-охват продукта: только Екатеринбург ────────────────────────────────── @@ -159,6 +168,20 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: # price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк # (см. _PRICE_PER_M2_SANITY_MIN/MAX выше). # +# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора: +# - scraped_at > NOW() - LISTINGS_FRESH_DAYS: `is_active` на проде не означает +# «живо» — деактиватор протухших работает не на всех источниках, и в пуле +# висят объявления, не виденные месяцами. Прод-замер (пул location_index, +# bbox ЕКБ): 30 222 активных строк, свежих 11 453; медиана 172 984 ₽/м² по +# всему пулу против 163 363 по свежим. Порог тот же, что у эстиматора +# (LISTINGS_FRESH_DAYS) — витрина обязана смотреть на тот же пул, что и цена. +# - listing_segment guard (#1186): новостройки — девелоперский прайс, он +# завышает медиану и локальную, и городскую (прод: 19 003 новостройки из +# 30 222; медиана вторички 147 632 ₽/м²). NULL = legacy вторичка до м.011. +# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов +# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок: +# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246. +# # bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если # сам продукт уже скрейпит соседние города области (city-sweep): географию location_index # явно ограничил владелец продукта. @@ -173,6 +196,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text( AND price_per_m2 IS NOT NULL AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND (geo_precision IS DISTINCT FROM 'city') + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -196,6 +221,8 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text( AND price_per_m2 IS NOT NULL AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND (geo_precision IS DISTINCT FROM 'city') + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -235,6 +262,7 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[ "lat": lat, "lon": lon, "radius_m": radius_m, + "fresh_days": LISTINGS_FRESH_DAYS, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, "bbox_south": _EKB_BBOX_SOUTH, @@ -257,6 +285,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]: db.execute( _MEDIAN_PPM2_CITYWIDE_SQL, { + "fresh_days": LISTINGS_FRESH_DAYS, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, "bbox_south": _EKB_BBOX_SOUTH, diff --git a/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql b/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql new file mode 100644 index 00000000..5144181f --- /dev/null +++ b/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql @@ -0,0 +1,204 @@ +-- 211_sales_vs_listings_segment_guard.sql +-- Purpose: #2660 — «медианный торг» на витрине /sales-vs-listings считался с участием +-- ПЕРВИЧКИ. CTE window_listings в street_sales_vs_listings() (миграция 205) читает +-- listings без сегментного гарда #1186, поэтому в пару к ДКП-сделке вторички мог +-- встать лот застройщика: девелоперский прайс (фиксированный, без торга) формирует +-- показываемый пользователю процент скидки. +-- +-- Прод-замер (2026-08-05, БД tradein): +-- - популяция кандидатов на пейринг (окно period_months+6 = 30 мес, price_rub > 0): +-- 93 241 строк, из них novostroyki 25 428 (27.3%), vtorichka 66 256, +-- listing_segment IS NULL 1 557 (legacy до м.011 — считаем вторичкой); +-- - симуляция по 20 самым «густым» (улица, комнаты) ЕКБ (2 120 сделок): +-- до гарда 1 219 сделок получили listing-match, из них 299 (24.5%) — против +-- новостройки; медианный торг −18.18%. После гарда: 994 матча, медиана −17.11% +-- (+1.07 п.п. — торг перестаёт «утяжеляться» за счёт первички). +-- +-- Что НЕ меняется и почему: +-- - `is_active` в window_listings НЕ добавляем — осознанно (см. 067/205): функция +-- намеренно смотрит и снятые объявления, иначе к сделке нечего подставить +-- (объявление снимают ПОСЛЕ продажи — активные для пейринга бесполезны). +-- - Свежесть здесь тоже не при чём по той же причине: пейринг привязан к дате +-- сделки (window_days ± grace), а не к «сейчас». +-- - Сигнатура функции НЕ меняется (те же 7 аргументов, что после м.205). +-- CREATE OR REPLACE FUNCTION с ИЗМЕНЁННЫМ списком типов создал бы ВТОРУЮ +-- перегрузку вместо замены (грабли #2627, разобраны в шапке 205) — здесь +-- список типов побайтово тот же, значит замена in-place, оверлоада не будет. +-- DROP FUNCTION намеренно НЕ делаем: 205 уже дропнула старую 6-арг сигнатуру, +-- а дропать текущую 7-арг перед CREATE — лишний оконный простой для caller'а. +-- +-- Сегментный предикат — канонический #1186 (тот же, что в _COMMON_WHERE эстиматора +-- и в 109_asking_to_sold_ratio_segment_filter.sql): +-- (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka') +-- NULL = legacy вторичка до миграции 011 (rosreestr/avito/yandex без сегмента), её +-- отбрасывать нельзя — потеряли бы 1 557 валидных кандидатов. +-- +-- ЗАВИСИМОСТИ: 205 (текущее тело + 7-арг сигнатура), listings.listing_segment (м.011). +-- Deploy order: после 210. Идемпотентность: CREATE OR REPLACE + COMMENT ON — оба +-- по конструкции re-run safe, ре-apply просто перезапишет то же тело. +-- Caller (app/api/v1/trade_in.py, /sales-vs-listings) не меняется — сигнатура и +-- RETURNS TABLE те же. + +BEGIN; + +CREATE OR REPLACE FUNCTION street_sales_vs_listings( + p_street_pattern text, + p_area_m2 numeric, + p_rooms integer, + p_window_days integer DEFAULT 180, + p_area_tolerance numeric DEFAULT 0.15, + p_period_months integer DEFAULT 24, + p_target_city text DEFAULT NULL +) +RETURNS TABLE ( + deal_id bigint, + deal_date date, + deal_price_rub bigint, + deal_price_per_m2 integer, + deal_area_m2 numeric, + deal_rooms integer, + deal_floor integer, + deal_address text, + listing_id bigint, + listing_source text, + listing_source_url text, + listing_date date, + listing_price_rub bigint, + listing_price_per_m2 integer, + listing_area_m2 numeric, + days_listing_to_deal integer, + discount_pct numeric +) +LANGUAGE sql +STABLE +AS $$ + WITH window_deals AS ( + -- Сделки в улице + период. Фильтр по rooms + area + (#2583 H4) city. + SELECT + d.id AS deal_id, + d.deal_date AS deal_date, + d.price_rub AS deal_price_rub, + d.price_per_m2 AS deal_price_per_m2, + d.area_m2 AS deal_area_m2, + d.rooms AS deal_rooms, + d.floor AS deal_floor, + d.address AS deal_address + FROM deals d + WHERE d.source = 'rosreestr' + AND d.address ILIKE p_street_pattern + AND d.rooms = p_rooms + AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND d.deal_date > NOW() - (p_period_months || ' months')::interval + AND d.price_rub > 0 + -- #2583 H4: deals.city заполнена на 100% — строгое равенство. + -- NULL p_target_city (город вне словаря) → фильтр не применяется. + AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city)) + ), + window_listings AS ( + -- Кандидаты-listings на той же улице, rooms exact, area ±tolerance, + -- (#2583 H4) тот же город что deals-сторона, (#2660) только вторичка. + SELECT + l.id AS listing_id, + l.source AS listing_source, + l.source_url AS listing_source_url, + l.listing_date AS listing_date, + l.price_rub AS listing_price_rub, + l.price_per_m2 AS listing_price_per_m2, + l.area_m2 AS listing_area_m2, + l.rooms AS listing_rooms, + COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date + FROM listings l + WHERE l.address ILIKE p_street_pattern + AND l.rooms = p_rooms + AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND l.price_rub > 0 + AND COALESCE(l.listing_date, l.scraped_at::date) + > NOW() - ((p_period_months + 6) || ' months')::interval + -- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%, + -- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим" + -- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий + -- фильтр выбросил бы почти все listings кроме avito. + AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)) + -- #2660 novostroyki guard (#1186): к ДКП-сделке вторички нельзя + -- подставлять лот застройщика — девелоперский прайс не торгуется и + -- уводит показываемый «медианный торг». Прод: 27.3% кандидатов — + -- первичка. NULL = legacy вторичка до м.011, оставляем. + AND (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka') + ), + paired AS ( + -- LEFT JOIN: сохраняем все сделки даже если нет listing match. + -- Для каждой сделки выбираем listing с listing_date ближайший + -- к deal_date (предпочтительно перед сделкой). + SELECT DISTINCT ON (wd.deal_id) + wd.deal_id, + wd.deal_date, + wd.deal_price_rub, + wd.deal_price_per_m2, + wd.deal_area_m2, + wd.deal_rooms, + wd.deal_floor, + wd.deal_address, + wl.listing_id, + wl.listing_source, + wl.listing_source_url, + wl.listing_date, + wl.listing_price_rub, + wl.listing_price_per_m2, + wl.listing_area_m2, + (wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal, + CASE + WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0 + THEN ROUND( + (wd.deal_price_rub - wl.listing_price_rub)::numeric + / wl.listing_price_rub * 100, + 2 + ) + ELSE NULL + END AS discount_pct + FROM window_deals wd + LEFT JOIN window_listings wl + ON wl.listing_event_date + BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date + AND (wd.deal_date + interval '30 days')::date + ORDER BY + wd.deal_id, + -- prefer listing event дата перед сделкой и ближе к ней + CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END, + CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END, + ABS((wd.deal_date - wl.listing_event_date)) + ) + SELECT + deal_id, + deal_date, + deal_price_rub, + deal_price_per_m2, + deal_area_m2, + deal_rooms, + deal_floor, + deal_address, + listing_id, + listing_source, + listing_source_url, + listing_date, + listing_price_rub, + listing_price_per_m2, + listing_area_m2, + days_listing_to_deal, + discount_pct + FROM paired + ORDER BY deal_date DESC; +$$; + +COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS + 'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, ' + 'city-filter #2583 H4 (миграция 205), segment-guard #2660/#1186 (миграция 211). ' + 'Per-street matching: address ILIKE, area ±tolerance, rooms exact, window_days ' + 'до даты сделки (+30д grace), city-scope (p_target_city, deals строго / listings ' + 'терпимо к NULL), listings — только вторичка (listing_segment IS NULL или ' + 'vtorichka). Возвращает LEFT JOIN — сделки без listing match имеют ' + 'listing_* = NULL. discount_pct = (deal - listing) / listing * 100. ' + 'is_active намеренно НЕ фильтруется: снятые объявления и есть материал пейринга.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index 63c0be4b..4b715fe2 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -138,6 +138,54 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None: assert "bbox_west" in sql_text and "bbox_east" in sql_text +# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ───────────── + + +def test_median_queries_filter_stale_listings() -> None: + """#2660: `is_active` на проде НЕ означает «живо» — деактиватор протухших покрывает не + все источники. Без предиката свежести медиана считалась по мертвецам: прод-замер по + пулу location_index (bbox ЕКБ) — 172 984 ₽/м² по всему активному пулу против + 163 363 по свежим. Оба запроса (локальный и общегородской) должны фильтровать + симметрично, иначе индекс = отношение двух разных популяций.""" + for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): + sql_text = str(sql.text) + assert "scraped_at > NOW() - (:fresh_days || ' days')::interval" in sql_text + + +def test_median_queries_exclude_novostroyki() -> None: + """Сегментный гард #1186 — тот же канонический предикат, что в _COMMON_WHERE + эстиматора. NULL = legacy вторичка до м.011 и должен остаться в пуле.""" + for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): + sql_text = str(sql.text) + assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql_text + + +def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None: + """Второе определение свежести разъедется с эстиматором при первой перекалибровке — + витрина начнёт показывать пул, отличный от того, на котором считается цена.""" + from app.services import estimator + + assert lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS + + +def test_both_median_queries_bind_the_same_freshness_window() -> None: + """Свежесть должна реально долетать в оба запроса bind-параметром (а не остаться + только в тексте SQL) и быть ОДИНАКОВОЙ — иначе локальная и городская медианы + считаются по разным окнам, и индекс — отношение несопоставимых величин.""" + db = _FakeDB( + [ + _FakeResult(scalar_value=0), # poi mirror empty + _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide + _FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0] + ] + ) + lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) + citywide_params = db.executed[1][1] + local_params = db.executed[2][1] + assert citywide_params["fresh_days"] == lc.LISTINGS_FRESH_DAYS + assert local_params["fresh_days"] == lc.LISTINGS_FRESH_DAYS + + # ── _fetch_nearby_poi (qualitative "что рядом" list) ───────────────────────── diff --git a/tradein-mvp/backend/tests/test_admin_stale_counters.py b/tradein-mvp/backend/tests/test_admin_stale_counters.py new file mode 100644 index 00000000..4fd4cbf1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_stale_counters.py @@ -0,0 +1,205 @@ +"""#2660: админ-счётчики честно разделяют «активно» и «из них не виделись N дней». + +Почему это баг, а не косметика: `is_active` снимается только деактиватором протухших, +а он покрывает не все источники — прод (2026-08-05) показывал cian 18 530 «активных» +при 12 683 не виденных 14+ дней и 10 212 не виденных 30+. Именно поэтому #2574 месяц +читалась как «всё собирается». Счётчик не прячем (иначе потеряем видимость проблемы) — +отдаём вторым числом рядом. + +Покрыто (db мокается, NO live network/DB): + - GET /api/v1/admin/scraper/data-quality — stale_count per source + stale_days; + - GET /api/v1/trade-in/cache-stats — listings_active_stale + listings_stale_days; + - порог свежести = LISTINGS_FRESH_DAYS эстиматора (не второе определение); + - оба запроса считают протухшее по last_seen_at И только среди is_active. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +class _FakeResult: + def __init__( + self, + *, + rows: list[dict] | None = None, + one: dict | None = None, + scalar_value: Any = None, + ) -> None: + self._rows = rows or [] + self._one = one + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + def mappings(self) -> Any: + outer = self + + class _Mappings: + def all(self) -> list[dict]: + return outer._rows + + def one(self) -> dict: + assert outer._one is not None + return outer._one + + def fetchone(self) -> dict | None: + return outer._one + + return _Mappings() + + +class _FakeDB: + """Session stand-in: execute() отдаёт очередь заготовленных результатов.""" + + def __init__(self, results: list[_FakeResult]) -> None: + self._results = list(results) + self.executed: list[tuple[Any, Any]] = [] + + def execute(self, clause: Any, params: Any = None) -> _FakeResult: + self.executed.append((clause, params)) + return self._results.pop(0) + + +# Одна строка coverage-выборки: source + счётчики + fill-агрегаты по всем полям. +def _dq_row(source: str, active: int, stale: int) -> dict[str, Any]: + from app.api.v1.admin import _DQ_LISTING_FIELDS + + row: dict[str, Any] = {"source": source, "active_count": active, "stale_count": stale} + for name, _expr in _DQ_LISTING_FIELDS: + row[f"f_{name}"] = active # 100% fill — не предмет этого теста + return row + + +_HOUSES_ROW = {"total": 10, "validated_cnt": 5, "rating_cnt": 5, "house_type_cnt": 5} + + +def _make_client(db: _FakeDB, router_module: str, prefix: str) -> TestClient: + import importlib + + from app.core.db import get_db + + module = importlib.import_module(router_module) + app = FastAPI() + app.include_router(module.router, prefix=prefix) + app.dependency_overrides[get_db] = lambda: db + return TestClient(app) + + +# ── GET /scraper/data-quality ──────────────────────────────────────────────── + + +@pytest.fixture +def dq_db() -> _FakeDB: + return _FakeDB( + [ + _FakeResult( + rows=[ + # Прод-пропорции 2026-08-05: cian почти весь протухший, avito живой. + _dq_row("cian", 18_530, 12_683), + _dq_row("avito", 4_975, 0), + ] + ), + _FakeResult(one=_HOUSES_ROW), + _FakeResult(scalar_value=0), + ] + ) + + +def test_data_quality_reports_stale_next_to_active(dq_db: _FakeDB) -> None: + """«Активно» остаётся как было, рядом появляется «из них не виделись N дней».""" + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + r = client.get("/api/v1/admin/scraper/data-quality") + assert r.status_code == 200 + body = r.json() + by_source = {s["source"]: s for s in body["sources"]} + assert by_source["cian"]["active_count"] == 18_530 + assert by_source["cian"]["stale_count"] == 12_683 + # Живой источник — протухших нет, счётчик не «шумит» на здоровых данных. + assert by_source["avito"]["active_count"] == 4_975 + assert by_source["avito"]["stale_count"] == 0 + + +def test_data_quality_exposes_the_threshold_used(dq_db: _FakeDB) -> None: + """Порог отдаётся в ответе — UI подписывает число, а не хардкодит своё N.""" + from app.services.estimator import LISTINGS_FRESH_DAYS + + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + body = client.get("/api/v1/admin/scraper/data-quality").json() + assert body["stale_days"] == LISTINGS_FRESH_DAYS + + +def test_data_quality_counts_stale_by_last_seen_within_active_only(dq_db: _FakeDB) -> None: + """Протухшее считается по last_seen_at (буквально «не виделись») и ТОЛЬКО среди + активных — иначе счётчик смешает деактивированные строки и перестанет быть + сопоставимым с active_count.""" + from app.services.estimator import LISTINGS_FRESH_DAYS + + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + client.get("/api/v1/admin/scraper/data-quality") + clause, params = dq_db.executed[0] + sql = str(clause) + assert "COUNT(*) FILTER (" in sql + assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql + assert "WHERE is_active = true" in sql + assert params == {"fresh_days": LISTINGS_FRESH_DAYS} + + +# ── GET /trade-in/cache-stats ──────────────────────────────────────────────── + + +def _cache_stats_row() -> dict[str, Any]: + return { + "geocode_cache": 1, + "geocode_cache_fresh": 1, + "listings_active": 37_900, + "listings_active_stale": 20_935, + "listings_last_scraped": None, + "deals": 0, + "cad_buildings": 0, + "house_metadata": 0, + "estimates_total": 0, + "avg_median_price": None, + "repeat_address_pct": None, + } + + +def test_cache_stats_splits_active_and_stale() -> None: + from app.services.estimator import LISTINGS_FRESH_DAYS + + db = _FakeDB([_FakeResult(one=_cache_stats_row())]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + body = client.get("/api/v1/trade-in/cache-stats").json() + assert body["listings_active"] == 37_900 + assert body["listings_active_stale"] == 20_935 + assert body["listings_stale_days"] == LISTINGS_FRESH_DAYS + + +def test_cache_stats_counts_stale_by_last_seen_within_active_only() -> None: + from app.services.estimator import LISTINGS_FRESH_DAYS + + db = _FakeDB([_FakeResult(one=_cache_stats_row())]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + client.get("/api/v1/trade-in/cache-stats") + clause, params = db.executed[0] + sql = str(clause) + assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql + assert "listings_active_stale" in sql + assert params == {"fresh_days": LISTINGS_FRESH_DAYS} + + +def test_cache_stats_empty_db_returns_empty_dict_not_a_fake_threshold() -> None: + """Нет строки — отдаём {}, а не {listings_stale_days: N} с пустыми счётчиками + (иначе UI отрисует подпись к несуществующему числу).""" + db = _FakeDB([_FakeResult(one=None)]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + assert client.get("/api/v1/trade-in/cache-stats").json() == {} diff --git a/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py b/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py new file mode 100644 index 00000000..43e40818 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py @@ -0,0 +1,207 @@ +"""Static guards для миграции 211 (сегментный гард #1186 в street_sales_vs_listings(), #2660). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP), полный DB-прогон требует живой +БД — здесь фиксируем структурные инварианты: + - транзакционность + идемпотентность (CREATE OR REPLACE, без DROP/TRUNCATE); + - гард #1186 стоит именно в window_listings (listings-сторона), а не в window_deals + (у deals нет listing_segment) и не в paired; + - `is_active` НЕ появился — его отсутствие осознанно (снятые объявления и есть + материал пейринга), см. шапку 205/211; + - сигнатура НЕ изменилась относительно 205 — иначе CREATE OR REPLACE создаст ВТОРУЮ + перегрузку вместо замены (грабли #2627), и caller получит старое тело; + - city-предикаты #2583 H4 (обе стороны JOIN) на месте — не потеряны при переносе тела; + - RETURNS TABLE не переименована — endpoint читает колонки по имени. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_205 = _SQL_DIR / "205_sales_vs_listings_city_filter.sql" +_MIGRATION_211 = _SQL_DIR / "211_sales_vs_listings_segment_guard.sql" + +_SIGNATURE_TYPES = ("text", "numeric", "integer", "integer", "numeric", "integer", "text") +_SEGMENT_GUARD = "(l.listing_segment is null or l.listing_segment = 'vtorichka')" + + +def _sql(path: Path = _MIGRATION_211) -> str: + return path.read_text(encoding="utf-8") + + +def _executable_sql(path: Path = _MIGRATION_211) -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql(path).splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def _function_body(path: Path = _MIGRATION_211) -> str: + """Тело функции между `AS $$` и `$$;`, без `--`-комментариев, нормализованное. + + Отдельно от _executable_sql(): COMMENT ON FUNCTION — тоже исполняемый SQL, и его + текст описывает фильтры словами (в т.ч. «is_active намеренно не фильтруется»). + Проверки «такого предиката в запросе нет» обязаны смотреть только на тело. + """ + sql = _sql(path) + start = sql.index("AS $$") + end = sql.index("$$;", start) + body = "\n".join( + line.split("--", 1)[0] for line in sql[start:end].splitlines() if line.split("--", 1)[0] + ) + return _flat(body) + + +def _function_signature(path: Path) -> str: + """Блок `CREATE OR REPLACE FUNCTION ...(...)` до RETURNS TABLE, нормализованный.""" + sql = _sql(path) + start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(") + end = sql.index("RETURNS TABLE", start) + return _flat(sql[start:end]) + + +def test_migration_211_exists() -> None: + assert _MIGRATION_211.exists(), f"missing migration: {_MIGRATION_211}" + + +def test_migration_211_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_211_no_destructive_ddl() -> None: + """Только CREATE OR REPLACE FUNCTION + COMMENT — таблицы не трогаем.""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "alter table" not in flat + assert "truncate" not in flat + assert "delete from" not in flat + + +def test_migration_211_segment_guard_present_in_window_listings() -> None: + """Гард #1186 — канонический предикат, НЕ `= 'vtorichka'` без NULL-ветки + (NULL = legacy вторичка до м.011; прод: 1 557 таких кандидатов).""" + flat = _flat(_executable_sql()) + assert _SEGMENT_GUARD in flat + # Гард стоит в listings-CTE, а не где-то ниже по тексту (в paired/финальном SELECT). + window_listings_pos = flat.index("window_listings as") + paired_pos = flat.index("paired as") + guard_pos = flat.index(_SEGMENT_GUARD) + assert window_listings_pos < guard_pos < paired_pos + + +def test_migration_211_does_not_add_is_active_filter() -> None: + """Отсутствие is_active — ОСОЗНАННОЕ (#2660): функция намеренно смотрит и снятые + объявления, иначе к сделке нечего подставить (объявление снимают ПОСЛЕ продажи).""" + assert "is_active" not in _function_body() + + +def test_migration_211_does_not_add_freshness_filter() -> None: + """Свежесть здесь тоже не при чём: пейринг привязан к дате СДЕЛКИ (window_days), + а не к «сейчас». Регресс-гард против копипасты предиката из location_index.""" + body = _function_body() + assert "l.scraped_at > now()" not in body + assert "last_seen_at" not in body + + +def test_migration_211_signature_identical_to_205_no_new_overload() -> None: + """#2627: CREATE OR REPLACE с ИЗМЕНЁННЫМ списком типов создаёт ВТОРУЮ перегрузку, + а не замену — caller продолжит попадать в старое тело. Сигнатура 211 обязана + совпадать с 205 побайтово (после нормализации пробелов).""" + assert _function_signature(_MIGRATION_211) == _function_signature(_MIGRATION_205) + + +def test_migration_211_does_not_drop_current_signature() -> None: + """DROP текущей 7-арг сигнатуры не нужен (замена и так in-place) и вреден — + между DROP и CREATE caller получил бы `function does not exist`.""" + flat = _flat(_executable_sql()) + new_sig = "street_sales_vs_listings(" + ", ".join(_SIGNATURE_TYPES) + ")" + assert f"drop function if exists {new_sig}" not in flat + assert "drop function" not in flat + + +def test_migration_211_exactly_one_function_definition() -> None: + """Ровно одно CREATE OR REPLACE — иначе последнее молча перетрёт предыдущее.""" + flat = _flat(_executable_sql()) + assert flat.count("create or replace function street_sales_vs_listings") == 1 + + +def test_migration_211_comment_targets_the_7arg_signature() -> None: + """COMMENT ON FUNCTION со списком типов — иначе комментарий сядет на + несуществующий оверлоад / упадёт на ambiguity.""" + flat = _flat(_executable_sql()) + new_sig = "street_sales_vs_listings(" + ", ".join(_SIGNATURE_TYPES) + ")" + assert f"comment on function {new_sig}" in flat + + +def test_migration_211_keeps_city_predicates_from_205() -> None: + """Тело переносится целиком — city-скоуп #2583 H4 (обе стороны JOIN) не потерян.""" + flat = _flat(_executable_sql()) + assert "(p_target_city is null or lower(d.city) = lower(p_target_city))" in flat + assert ( + "(p_target_city is null or l.city is null or lower(l.city) = lower(p_target_city))" in flat + ) + + +def test_migration_211_return_table_shape_unchanged() -> None: + """RETURNS TABLE читается endpoint'ом по имени через .mappings() — любое + переименование/удаление сломало бы response mapping без явного сигнала.""" + sql = _sql() + for col in ( + "deal_id", + "deal_date", + "deal_price_rub", + "deal_price_per_m2", + "deal_area_m2", + "deal_rooms", + "deal_floor", + "deal_address", + "listing_id", + "listing_source", + "listing_source_url", + "listing_date", + "listing_price_rub", + "listing_price_per_m2", + "listing_area_m2", + "days_listing_to_deal", + "discount_pct", + ): + assert col in sql, f"missing column in RETURNS TABLE: {col}" + + +def test_migration_211_no_psycopg_cast_trap() -> None: + """Регресс-гард против copy-paste `:param::type` (в чистом .sql bind-параметров нет, + но файл переносится в Python-тесты/скрипты).""" + assert not re.search(r":\w+::", _sql()) + + +def test_migration_211_body_matches_205_except_the_guard() -> None: + """Тело — байт-в-байт копия 205 плюс ровно один новый предикат. Защищает от + случайной потери логики при ручном переносе большого SQL-блока.""" + + def body(path: Path) -> list[str]: + sql = _sql(path) + start = sql.index("AS $$") + end = sql.index("$$;", start) + # Только исполняемые строки, без комментариев — их мы правим осознанно. + return [ + _flat(line.split("--", 1)[0]) + for line in sql[start:end].splitlines() + if line.split("--", 1)[0].strip() + ] + + old_body = body(_MIGRATION_205) + new_body = body(_MIGRATION_211) + added = [line for line in new_body if line not in old_body] + removed = [line for line in old_body if line not in new_body] + assert added == ["and " + _SEGMENT_GUARD], added + assert removed == [], removed diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index 6e472637..ab6d29f7 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -357,10 +357,12 @@ def _make_dq_db_mock() -> MagicMock: """Мок db-сессии для GET /scraper/data-quality.""" db = MagicMock() - # listings rows: avito=1000 active, cian=500 active + # listings rows: avito=1000 active (все свежие), cian=500 active / 300 протухших + # (#2660: stale_count идёт рядом с active_count — «активно» ≠ «живо»). avito_row = { "source": "avito", "active_count": 1000, + "stale_count": 0, "f_description": 900, "f_photo_urls": 980, "f_address": 1000, @@ -375,6 +377,7 @@ def _make_dq_db_mock() -> MagicMock: cian_row = { "source": "cian", "active_count": 500, + "stale_count": 300, "f_description": 450, "f_photo_urls": 490, "f_address": 500, @@ -435,6 +438,7 @@ def test_data_quality_shape(client: TestClient) -> None: avito = sources["avito"] assert avito["active_count"] == 1000 + assert avito["stale_count"] == 0 # #2660 fields = avito["fields"] assert 0.0 <= fields["description"] <= 100.0 assert fields["description"] == pytest.approx(90.0, abs=0.1) @@ -443,6 +447,7 @@ def test_data_quality_shape(client: TestClient) -> None: cian = sources["cian"] assert cian["active_count"] == 500 + assert cian["stale_count"] == 300 # #2660: 60% «активных» на деле не виделись assert cian["fields"]["ceiling_height"] == pytest.approx(70.0, abs=0.1) assert cian["fields"]["ceiling_height_m"] == pytest.approx(0.0, abs=0.1) From b800760c247a441db691883cabe2e5e52a1a6a60 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:10:47 +0500 Subject: [PATCH 005/130] =?UTF-8?q?fix(tradein/scraper):=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=20skipped=20=D0=B2=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=BA=D0=B5=20+=20=D0=BE=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=85=D0=BB=D0=BE=D0=BF?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20(#2658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правки по ревью PR #2662. Фильтр статуса. `GET /admin/scrape/runs?status=skipped` отдавал 422 — 'skipped' не было в Literal, а во фронте не было чипа. Строки рисовались, но задать вопрос «что сейчас пропускается» на единственной поверхности, построенной ровно для этого, было нельзя. Добавлено в оба места (translateStatus «пропущено» и нейтральный бейдж уже умели). Схлопывание освежает строку. UPDATE двигал только finished_at/heartbeat_at, из-за чего живой стрик замерзал: списки прогонов сортируют ORDER BY started_at DESC и берут limit=20, поэтому 37-дневный пропуск утонул бы под свежими прогонами других источников — след в базе есть, на экране нет. Теперь started_at = NOW(), а начало стрика переезжает в counters.first_skip_at; сортировку общего списка не трогаем (она про все источники, чинить надо было одну строку). Там же обновляется counters.detail — иначе в строке 37 дней висел текст «протухли 1 день назад», хотя именно эта цифра и есть предмет issue. jsonb_set заменён на `||` + jsonb_build_object: три вложенных jsonb_set читать в 3 ночи невозможно, а NULL в jsonb_set обнуляет весь counters. Поиск последней строки. `ORDER BY id DESC` не ложится на индекс (source, started_at DESC) из миграции 015 — для unknown_source (тикает каждые 60 с бессрочно) это отбор всех строк источника с сортировкой раз в минуту. Теперь ORDER BY started_at DESC, id DESC. session_expires_at получил valid_only: предупреждение «скоро протухнут» считает срок ИМЕННО той записи, которую взял load_session — при нескольких аккаунтах свежайшая-любая может быть чужой протухшей строкой. Диагностика после None по-прежнему смотрит на свежайшую любую (валидных там нет по определению). Запись пропуска намеренно НЕ обёрнута в свой try/except: если db.execute падает, то падает и claim следующего расписания в этом же тике — тик срывается в любом случае, а глушить исключение здесь значило бы вернуть ровно тот немой пропуск, ради которого заведён #2658. Самовосстановление через 60 с. --- tradein-mvp/backend/app/api/v1/admin.py | 8 ++- .../backend/app/services/cian_session.py | 18 ++++-- .../backend/app/services/product_handlers.py | 4 +- .../tests/test_scrape_skip_visibility.py | 55 +++++++++++++++++++ .../src/components/scrapers/RunsTable.tsx | 13 ++++- .../src/scraper_kit/orchestration/runs.py | 29 +++++++--- 6 files changed, 109 insertions(+), 18 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 4a803c64..7b19935b 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2262,7 +2262,11 @@ def list_scrape_runs_unified( db: Annotated[Session, Depends(get_db)], source: Annotated[str | None, Query()] = None, status: Annotated[ - Literal["done", "running", "banned", "zombie", "failed", "cancelled"] | None, Query() + # 'skipped' (#2658) — пропущенное расписание; без него оператор не может + # спросить «что сейчас пропускается» (фильтр отдавал 422 на единственной + # поверхности, построенной ровно для этого вопроса). + Literal["done", "running", "banned", "zombie", "failed", "cancelled", "skipped"] | None, + Query(), ] = None, limit: Annotated[int, Query(ge=1, le=200)] = 50, offset: Annotated[int, Query(ge=0)] = 0, @@ -2274,7 +2278,7 @@ def list_scrape_runs_unified( Query: source — опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...). - status — опц. фильтр (done/running/banned/zombie/failed/cancelled). + status — опц. фильтр (done/running/banned/zombie/failed/cancelled/skipped). limit — default 50, max 200. offset — default 0. """ diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py index df1763d0..6e227881 100644 --- a/tradein-mvp/backend/app/services/cian_session.py +++ b/tradein-mvp/backend/app/services/cian_session.py @@ -300,22 +300,30 @@ def load_session(db: Session) -> dict[str, str] | None: return cookies -def session_expires_at(db: Session) -> datetime | None: - """Когда протухают самые свежезагруженные куки — БЕЗ фильтра валидности (#2658). +def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None: + """Когда протухают самые свежезагруженные куки (#2658). `load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от - «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. Здесь фильтра нет: None означает - ровно «записей нет вовсе». + «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. + + valid_only=False (диагностика после None от load_session) — свежайшая запись любая: + валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же + запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок + ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке. """ row = db.execute( text( """ SELECT expires_at_estimate FROM cian_session_cookies + WHERE NOT CAST(:valid_only AS boolean) + OR (expires_at_estimate > NOW() + AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)) ORDER BY uploaded_at DESC LIMIT 1 """ - ) + ), + {"valid_only": valid_only}, ).first() if row is None: return None diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index b89ba26e..ffdaf553 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -121,7 +121,9 @@ async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: Schedu return False # Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора. - expires_at = session_expires_at(db) + # valid_only=True: срок ИМЕННО той записи, которую взял load_session (при нескольких + # аккаунтах свежайшая-любая может быть чужой протухшей строкой). + expires_at = session_expires_at(db, valid_only=True) if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS): logger.error( "scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, " diff --git a/tradein-mvp/backend/tests/test_scrape_skip_visibility.py b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py index 3e8caf4b..a16b5ae2 100644 --- a/tradein-mvp/backend/tests/test_scrape_skip_visibility.py +++ b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py @@ -115,6 +115,40 @@ def test_mark_skipped_collapses_consecutive_same_reason() -> None: assert "skips" in sql # счётчик повторов растёт вместо новой строки +def test_mark_skipped_collapse_refreshes_detail_and_started_at() -> None: + """Схлопывание освежает строку: detail, started_at (иначе живой стрик тонет в списке). + + Список прогонов сортирует ORDER BY started_at DESC с limit=20 — замороженный + started_at утопил бы 37-дневный стрик под свежими прогонами других источников. + Начало стрика сохраняется в counters.first_skip_at. + """ + db = _FakeSkipDB(collapse=True) + kit_runs.mark_skipped( + db, source="cian_history_backfill", reason="cian_cookies_expired", details="37 дн. назад" + ) + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, params = update + assert "started_at = NOW()" in sql + assert "'detail', CAST(:details AS text)" in sql, "detail замерзает от первого пропуска" + assert "first_skip_at" in sql, "начало стрика потеряно" + assert params["details"] == "37 дн. назад" + + +def test_mark_skipped_latest_lookup_uses_indexed_order() -> None: + """Поиск последней строки идёт по (source, started_at DESC) — индекс из миграции 015. + + ORDER BY id DESC этот индекс не использует: для unknown_source (тик каждые 60 с + бессрочно) это был бы отбор всех строк источника с сортировкой раз в минуту. + """ + db = _FakeSkipDB(collapse=True) + kit_runs.mark_skipped(db, source="avito_full_load", reason=SKIP_ALREADY_RUNNING) + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, _params = update + assert "ORDER BY started_at DESC, id DESC" in sql + + # ── 2. пять мест: kit _claim_run ×3 ────────────────────────────────────────── @@ -394,6 +428,9 @@ async def test_cian_pre_claim_warns_before_expiry_not_after() -> None: proceed = await product_handlers._cian_pre_claim( MagicMock(), _make_sched("cian_history_backfill"), MagicMock() ) + # Срок считаем по ЗАПИСИ, которую взял load_session: при нескольких аккаунтах + # свежайшая-любая может быть чужой протухшей строкой (валидности она не знает). + assert cian_session.session_expires_at.call_args.kwargs == {"valid_only": True} finally: product_handlers.logger.removeHandler(handler) @@ -469,6 +506,24 @@ def test_zero_result_monitor_ignores_skipped_rows() -> None: assert "skipped" not in sql +def test_admin_runs_filter_accepts_skipped_status() -> None: + """GET /admin/scrape/runs?status=skipped не должен отдавать 422. + + Единственная поверхность, где оператор спрашивает «что сейчас пропускается» — фильтр + статуса в таблице прогонов. Без 'skipped' в Literal строки видны, а вопрос задать + нельзя, то есть цель #2658 на UI достигнута наполовину. + """ + import typing + + from app.api.v1.admin import list_scrape_runs_unified + + annotation = typing.get_type_hints(list_scrape_runs_unified, include_extras=True)["status"] + allowed: set[str] = set() + for arg in typing.get_args(typing.get_args(annotation)[0]): + allowed.update(typing.get_args(arg)) + assert "skipped" in allowed + + def test_mark_skipped_status_is_not_a_failure_status() -> None: """Строка-пропуск не попадает и в failed/banned-стрик (алерт «3 подряд ошибки»).""" db = _FakeSkipDB(collapse=False) diff --git a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx index a6549a3f..3544eb47 100644 --- a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx @@ -34,7 +34,18 @@ interface RunsListResp { // ── Hook ─────────────────────────────────────────────────────────────────── -const RUN_STATUS_ALL = ["", "running", "done", "failed", "cancelled", "zombie", "banned"] as const; +// "skipped" (#2658) — пропущенное расписание (нет кук / уже бежит / нет handler'а); +// translateStatus уже знает «пропущено», бейдж падает в нейтральный вариант. +const RUN_STATUS_ALL = [ + "", + "running", + "done", + "failed", + "cancelled", + "zombie", + "banned", + "skipped", +] as const; type RunStatusFilter = (typeof RUN_STATUS_ALL)[number]; // "" means "all sources"; otherwise a specific source prefix (avito / cian / yandex) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index afa6c0a5..233bfc46 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -248,9 +248,17 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = `details` — человеческое пояснение, кладётся в `counters.detail`. Схлопывание подряд идущих одинаковых пропусков: если ПОСЛЕДНЯЯ строка прогона этого - source — уже 'skipped' с тем же reason, новая не создаётся; у существующей - обновляется finished_at и счётчик `counters.skips`. Без этого «уже бежит» и - «неизвестный source» плодили бы строку каждый тик (60 с), пока держится причина. + source — уже 'skipped' с тем же reason, новая не создаётся; у существующей обновляются + started_at/finished_at, счётчик `counters.skips` и свежий `counters.detail`. Без этого + «уже бежит» и «неизвестный source» плодили бы строку каждый тик (60 с), пока держится + причина. + + started_at двигаем намеренно: списки прогонов (`list_all`/`list_recent`) сортируют + ORDER BY started_at DESC и берутся с limit=20, поэтому замороженный started_at утопил + бы ЖИВОЙ стрик под свежими прогонами других источников — ровно тот сценарий, из-за + которого заведён #2658 (в базе след есть, на экране нет). Начало стрика при этом не + теряется: первый started_at переезжает в `counters.first_skip_at`. Сортировку общего + списка не трогаем — она про все источники, а чинить надо было одну строку. Возвращает id строки (новой или обновлённой). """ @@ -260,16 +268,19 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = WITH latest AS ( SELECT id FROM scrape_runs WHERE source = :source - ORDER BY id DESC + ORDER BY started_at DESC, id DESC LIMIT 1 ) UPDATE scrape_runs r SET heartbeat_at = NOW(), + started_at = NOW(), finished_at = NOW(), - counters = jsonb_set( - COALESCE(r.counters, '{}'::jsonb), - '{skips}', - to_jsonb(COALESCE(CAST(r.counters ->> 'skips' AS int), 0) + 1) + counters = COALESCE(r.counters, '{}'::jsonb) || jsonb_build_object( + 'skips', COALESCE(CAST(r.counters ->> 'skips' AS int), 0) + 1, + 'detail', CAST(:details AS text), + 'first_skip_at', COALESCE( + r.counters ->> 'first_skip_at', CAST(r.started_at AS text) + ) ) FROM latest WHERE r.id = latest.id @@ -278,7 +289,7 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = RETURNING r.id """ ), - {"source": source, "reason": reason}, + {"source": source, "reason": reason, "details": details}, ).fetchone() if row is None: From d173163025c63f25264dc897a5a6f00bdc9ac057 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:13:22 +0500 Subject: [PATCH 006/130] =?UTF-8?q?fix(tradein):=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=20=D0=BB=D0=BE=D0=B2=D0=B8=D1=82=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D1=8B,=20=D0=B0=20=D0=BD=D0=B5=20equality;=20=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20(#2660)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По ревью PR #2664. 1. test_freshness_window_is_the_estimator_constant_not_a_copy проверял `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` — CPython кэширует малые int, поэтому скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы ПРОШЁЛ, хотя докстринг обещает ловить ровно это. Прошлая фальсификация срабатывала лишь потому, что откат удалял имя целиком (AttributeError). Теперь проверяем исходник через inspect.getsource — фальсифицировано подстановкой копии литерала вместо импорта: тест краснеет. 2. Комментарий в location_index.py приписывал свежести чужую заслугу. Прод-разложение: из −14.8% сдвига городской медианы −14.7 п.п. даёт сегментный гард и лишь −0.18 п.п. свежесть. Для этой метрики свежесть — не коррекция смещения, а страховка на будущее, оплаченная третью пула (3 504 вторичных строки, из них 2 724 живые) и ростом дисперсии: на центре ЕКБ n 423 → 86, индекс гуляет по выбору окна на 12-14 п.п. Размен верный, но он должен быть написан как размен. Там же задокументирован новый режим отказа: свежесть связала витрину со здоровьем сбора — встанет скрейпинг на 14 дней, и insufficient_data прилетит всем пользователям разом. Учитывая, что #2574 это месяц молчаливой поломки сбора, сценарий не гипотетический. Окно свежести не меняю — вопрос вынесен отдельно. Refs #2660 --- .../backend/app/services/location_index.py | 43 ++++++++++++++----- .../tests/services/test_location_index.py | 15 ++++++- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py index 8e437b9d..8e1c67f3 100644 --- a/tradein-mvp/backend/app/services/location_index.py +++ b/tradein-mvp/backend/app/services/location_index.py @@ -168,16 +168,39 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: # price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк # (см. _PRICE_PER_M2_SANITY_MIN/MAX выше). # -# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора: -# - scraped_at > NOW() - LISTINGS_FRESH_DAYS: `is_active` на проде не означает -# «живо» — деактиватор протухших работает не на всех источниках, и в пуле -# висят объявления, не виденные месяцами. Прод-замер (пул location_index, -# bbox ЕКБ): 30 222 активных строк, свежих 11 453; медиана 172 984 ₽/м² по -# всему пулу против 163 363 по свежим. Порог тот же, что у эстиматора -# (LISTINGS_FRESH_DAYS) — витрина обязана смотреть на тот же пул, что и цена. -# - listing_segment guard (#1186): новостройки — девелоперский прайс, он -# завышает медиану и локальную, и городскую (прод: 19 003 новостройки из -# 30 222; медиана вторички 147 632 ₽/м²). NULL = legacy вторичка до м.011. +# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора. +# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение +# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision): +# +# было (только is_active) 30 222 строк 172 984 ₽/м² +# + только свежесть 11 453 строк 163 363 ₽/м² +# + только сегмент 11 219 строк 147 632 ₽/м² +# стало (оба) 7 715 строк 147 368 ₽/м² +# +# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из −14.8% +# сдвига городской медианы он даёт −14.7 п.п. Девелоперский прайс новостроек +# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011. +# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк +# пула 15 265 — новостройки, и гард выносит их заодно. +# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего +# −0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на +# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка — +# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных +# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней +# назад. Пул −31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а +# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% / +# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы. +# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число +# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо, +# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше). +# +# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем +# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт +# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до +# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что +# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический. +# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая. +# # Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов # упирается в отказ редко — прод-симуляция на 246 реальных точках оценок: # insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246. diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index 4b715fe2..cee70c8c 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -162,10 +162,21 @@ def test_median_queries_exclude_novostroyki() -> None: def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None: """Второе определение свежести разъедется с эстиматором при первой перекалибровке — - витрина начнёт показывать пул, отличный от того, на котором считается цена.""" + витрина начнёт показывать пул, отличный от того, на котором считается цена. + + Проверяем ИСХОДНИК, а не равенство значений: CPython кэширует малые int (−5…256), + поэтому `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` проходит и для + скопированного литерала `LISTINGS_FRESH_DAYS = 14` — то есть стережёт не то, что + обещает. Отсутствие присваивания в модуле = константа может быть только импортом. + """ + import inspect + from app.services import estimator - assert lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS + assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource( + lc + ), "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator" + assert lc.LISTINGS_FRESH_DAYS == estimator.LISTINGS_FRESH_DAYS def test_both_median_queries_bind_the_same_freshness_window() -> None: From 40fdf11f19f712d2a7a7117d9525ad80165b5bf7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:14:11 +0500 Subject: [PATCH 007/130] =?UTF-8?q?fix(tradein/auth):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D1=8F=D1=82=D1=8C=20=D0=B8=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=82=D1=8C=20=D0=BF=D1=83?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=B4=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20(#2?= =?UTF-8?q?571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью нашло два способа положить сервис ровно под той нагрузкой, ради которой писалась защита. Первый: `min()` вычисляет оба аргумента, поэтому `float(2 ** (excess - 1))` при 1045 неудачах по имени за окно падал с OverflowError. Счётчик ничем не ограничен сверху — `record()` только копит метки и на лимит не смотрит. С этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и без записи в аудит: терялись обе ценности PR, и трение, и сигнал. Показатель степени зажат; 2**16 заведомо выше любого разумного потолка, поэтому видимое поведение не меняется. Второй: сон шёл внутри области жизни сессии БД. В дефолтном режиме `get_identity_db` отдаёт ту же сессию, что `get_db`, а SELECT в `get_user_by_username` оставляет её в открытой транзакции — соединение висело занятым все восемь секунд. Пятнадцати одновременных неудач хватало, чтобы выбрать QueuePool целиком и уронить любой другой эндпоинт по pool_timeout. Отказ в обслуживании против всех сразу — хуже той блокировки учётки, ради ухода от которой замедление и выбиралось. Соединение теперь возвращается в пул перед сном. Заодно: длина имени ограничена 64 (верх CHECK'а реестра) — сырое имя становится ключом обоих лимитеров, а их словарь при часовом окне не подчищается; и явно записано, что `limit` у счётчика на имя не порог. --- tradein-mvp/backend/app/api/v1/auth.py | 48 ++++++++++++-- tradein-mvp/backend/tests/test_auth_api.py | 77 ++++++++++++++++++++++ 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index bd3b3ec8..96b4cd90 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -45,7 +45,7 @@ import secrets from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.config import settings @@ -83,6 +83,13 @@ _LOGIN_LIMITER = SlidingWindowLimiter( # Потолок: появятся воркеры (`--workers N`) — потолок делится на N, и его надо # переносить в Redis (`app.services.cache` уже держит там пул). Тот же ceiling # у соседнего `_LOGIN_LIMITER`; перезапуск процесса обнуляет оба. +# +# ⚠️ `limit` здесь НЕ ПОРОГ и ничего не режет: мы зовём только `record()`, а он +# на лимит не смотрит — считает и отдаёт число попыток в окне. Настоящий порог +# живёт в `_throttle_delay_s`, которая читает настройку на каждом вызове (и +# потому подхватывает monkeypatch в тестах). Значение продублировано сюда ровно +# для того, чтобы `retry_after()` на этом объекте — если его однажды позовут — +# отвечал по тому же числу, а не по случайному. _USERNAME_FAIL_LIMITER = SlidingWindowLimiter( limit=settings.login_username_fail_threshold, window_s=settings.login_username_fail_window_s, @@ -109,7 +116,15 @@ _ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился" class LoginRequest(BaseModel): - username: str + # max_length=64 — ровно верхняя граница CHECK'а реестра + # (`users_username_ascii_ck`, data/sql/auth/001), так что живое имя отсечь + # нельзя. Ограничение нужно не валидации ради: сырое имя становится ключом + # ОБОИХ лимитеров, а их `defaultdict` подчищается только при >10000 ключей и + # только от пустых корзин — при окне в час корзины непустые, освобождать + # нечего. Без границы длины килобайтные имена растили бы память ключами. + # Паттерн/минимум длины НЕ дублируем: в режиме `identity_store="tradein"` + # CHECK'а нет и живут не-ASCII имена (см. тест на кириллицу). + username: str = Field(max_length=64) password: str @@ -132,15 +147,23 @@ def _throttle_delay_s(fails_in_window: int) -> float: первые перебранные попытки почти незаметны, а сотни — упираются в потолок. Потолок обязателен: без него задержка становится той же блокировкой, только растянутой во времени. + + Показатель степени зажат (`min(..., 16)`) — это не косметика. `min()` считает + ОБА аргумента до сравнения, поэтому наивный `float(2 ** (excess - 1))` при + excess>=1025 падает с `OverflowError: int too large to convert to float` — + то есть ровно под целевой нагрузкой (1045 неудач по имени за час = 0.3 rps) + защита начинала отдавать 500 мгновенно и без аудита, вместо 401 с задержкой. + 2**16 = 65536с заведомо больше любого разумного потолка, так что зажим + видимого поведения не меняет, а арифметику делает безусловно конечной. """ excess = fails_in_window - settings.login_username_fail_threshold if excess <= 0: return 0.0 - return min(settings.login_username_throttle_max_delay_s, float(2 ** (excess - 1))) + return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16)) async def _reject_invalid_credentials( - username: str, ip: str, user_agent: str | None + db: Session, username: str, ip: str, user_agent: str | None ) -> HTTPException: """Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 401. @@ -157,6 +180,15 @@ async def _reject_invalid_credentials( Возвращает `HTTPException`, а не бросает: `raise await …` не собирается, а `raise (await …)` читается хуже, чем `raise` над возвращённым значением. + + *db* нужен ровно затем, чтобы ОТДАТЬ соединение перед сном. `get_identity_db` + в дефолтном режиме (`identity_store="tradein"`, он же прод) отдаёт ту же + сессию, что `get_db` — движок с QueuePool на 5+10 соединений. После SELECT в + `get_user_by_username` сессия держит соединение в открытой транзакции, и сон + внутри её области жизни превращал бы каждую спящую попытку в занятое + соединение: ~15 одновременных неудач выбирают пул целиком, и тогда ЛЮБОЙ + эндпоинт ждёт checkout 30с и падает. Отказ в обслуживании против всех сразу — + хуже той блокировки учётки, ради отказа от которой всё это писалось. """ fails = _USERNAME_FAIL_LIMITER.record(username) delay_s = _throttle_delay_s(fails) @@ -182,6 +214,10 @@ async def _reject_invalid_credentials( delay_s, ip, ) + # Соединение — в пул ДО сна (см. docstring). Сессия дальше не нужна: + # вызывающий немедленно делает raise, а повторный close() в самой + # зависимости идемпотентен. + db.close() # await, не time.sleep: событийный цикл в это время обслуживает всех # остальных — тормозим перебор, а не сервис. await asyncio.sleep(delay_s) @@ -221,7 +257,7 @@ async def login( # Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние # доступа. Порядок несущий, а не стилистический: см. модульный docstring. if user is None or not password_ok: - raise await _reject_invalid_credentials(body.username, ip, user_agent) + raise await _reject_invalid_credentials(db, body.username, ip, user_agent) access_state = user["access_state"] if access_state is AccessState.TRIAL_EXPIRED: @@ -247,7 +283,7 @@ async def login( # disabled (и любое нераспознанное состояние — to_access_state fail-closed) # → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном # пароле: заблокированный аккаунт неотличим от несуществующего. - raise await _reject_invalid_credentials(body.username, ip, user_agent) + raise await _reject_invalid_credentials(db, body.username, ip, user_agent) token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent) diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 48e9c406..52341829 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -445,6 +445,13 @@ def test_throttle_delay_grows_and_caps(monkeypatch: pytest.MonkeyPatch) -> None: assert auth_router._throttle_delay_s(6) == 4.0 assert auth_router._throttle_delay_s(7) == 4.0 # потолок assert auth_router._throttle_delay_s(1000) == 4.0 + # Счётчик ничем не ограничен сверху (`record()` только добавляет метку), а + # `min()` вычисляет ОБА аргумента. Без зажатого показателя степени + # `float(2 ** (excess - 1))` при ~1045 неудачах падает с OverflowError, и + # защита начинает отдавать 500 без задержки и без аудита — ровно под той + # нагрузкой, ради которой писалась. 1000 выше проходило впритык под обрывом. + assert auth_router._throttle_delay_s(5_000) == 4.0 + assert auth_router._throttle_delay_s(10**6) == 4.0 def test_distributed_bruteforce_one_username_many_ips_hits_global_ceiling( @@ -500,6 +507,43 @@ def test_throttle_actually_delays_the_response( assert elapsed >= 1.0 +def test_db_connection_released_before_sleeping( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Соединение с БД возвращается в пул ДО сна, а не удерживается всю задержку. + + На проде `get_identity_db` в дефолтном режиме отдаёт ту же сессию, что + `get_db` (движок с QueuePool 5+10, pool_timeout=30), а `get_user_by_username` + оставляет её в открытой транзакции. Сон внутри этой области жизни держал бы + соединение занятым все 8с: ~15 одновременно спящих неудач выбирают пул + целиком, и дальше ЛЮБОЙ эндпоинт ждёт checkout 30с и падает — отказ в + обслуживании против всех, ради ухода от которого замедление и выбиралось + вместо блокировки. + + Проверяем порядком, а не мокой пула: если `close()` случился до сна, между + ним и концом ответа лежит вся задержка; если бы сессию закрывала только + зависимость (то есть после сна) — зазор был бы околонулевым. + """ + store.add_user("holder", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0) + + closes: list[float] = [] + real_close = _FakeDB.close + + def _spy_close(self: _FakeDB) -> None: + closes.append(time.monotonic()) + real_close(self) + + monkeypatch.setattr(_FakeDB, "close", _spy_close) + + resp = client.post("/api/v1/auth/login", json={"username": "holder", "password": "wrong"}) + finished = time.monotonic() + + assert resp.status_code == 401 + assert closes, "сессия не закрывалась вовсе" + assert finished - closes[0] >= 1.0 + + def test_typo_does_not_throttle_and_correct_password_still_works( client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -523,6 +567,39 @@ def test_typo_does_not_throttle_and_correct_password_still_works( assert config.settings.session_cookie_name in ok.cookies +def test_counter_decays_when_window_passes(monkeypatch: pytest.MonkeyPatch) -> None: + """Вторая половина DoD 2: наказание не накапливается вечно. + + Окно скользящее, старые неудачи выпадают сами — снимать ничего вручную не + нужно. Проверяем на самом счётчике, а не через HTTP: один вызов login стоит + полного bcrypt (~0.25с), так что игрушечное окно истекало бы прямо посреди + цикла запросов и тест мерил бы скорость хеширования, а не спад счётчика. + """ + _throttle_settings(monkeypatch, threshold=1, max_delay_s=4.0) + limiter = auth_router._USERNAME_FAIL_LIMITER + monkeypatch.setattr(limiter, "_window_s", 0.2) + + assert [limiter.record("frank") for _ in range(3)] == [1, 2, 3] + assert auth_router._throttle_delay_s(3) > 0 + + time.sleep(0.25) # окно прошло — прошлые неудачи больше не считаются + + assert limiter.record("frank") == 1 + assert auth_router._throttle_delay_s(1) == 0.0 + + +def test_username_length_is_bounded(client: TestClient) -> None: + """Сырое имя становится ключом обоих лимитеров, а их словарь чистится только + при >10000 ключей и только от пустых корзин — при окне в час чистить нечего. + Границу длины держим на 64 (верх CHECK'а реестра), чтобы килобайтные имена + не растили память ключами.""" + resp = client.post("/api/v1/auth/login", json={"username": "x" * 65, "password": "p"}) + assert resp.status_code == 422 + # 64 — всё ещё валидная длина, отвечаем обычным generic-отказом. + ok_len = client.post("/api/v1/auth/login", json={"username": "x" * 64, "password": "p"}) + assert ok_len.status_code == 401 + + def test_throttle_identical_for_existing_and_unknown_username( client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch ) -> None: From 9b9f299922a194491f63d271beb3b8fc9b5cc05e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:37:48 +0500 Subject: [PATCH 008/130] =?UTF-8?q?fix(tradein/scraper):=20=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=20QRATOR=20=D1=83=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BC=D0=B5=D1=87=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D1=83=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=88=D0=BD=D1=8B=D0=BC=20(#2657)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honest-status в run_domclick_city_sweep требовал ОДНОВРЕМЕННО блок И ноль лотов, поэтому распознанный QRATOR-блок после первых собранных лотов уходил в `done`. На проде это 13 из 13 прогонов с blocked=1 (39-464 лота вместо ~6300) — ни один распознанный блок ни разу не дал не-`done` статус. Домклик структурно отличается от cian/yandex (#2625/#2642): там независимые anchor'ы и провал одного среди успешных — не бан (анти-флап). Здесь anchor'ов нет, sweep линейный по ROOM_BUCKETS, и первый же блок делает break — оставшиеся бакеты не пробуются вовсе. Значит блок = прогон оборван, сколько бы лотов он ни успел взять до этого. Теперь: blocked → mark_banned (external constraint, не наш баг; тот же статус, что #2642 дал cian/yandex — доступен как триггер ротации IP #2611, сама ротация не вызывается). Ноль лотов с fetch-ошибками, но БЕЗ блока → по-прежнему failed. Честная пустота → по-прежнему done. Пометка прокси-пула (fetcher.report_ban, #2600 п.1) не тронута — живёт в providers/domclick/serp.py и срабатывает раньше и независимо от статуса прогона. Refs #2657 --- .../backend/tests/test_domclick_sweep.py | 6 +-- .../test_scraper_kit_pipeline_parity2.py | 45 ++++++++++++++++--- .../src/scraper_kit/orchestration/pipeline.py | 42 +++++++++++++---- 3 files changed, 77 insertions(+), 16 deletions(-) diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index 9c122cc4..e84f377e 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -1,10 +1,10 @@ """Offline unit tests for DomClickCitySweepCounters + DomClick parser. Legacy `run_domclick_city_sweep` orchestration behavior (sweep phases, cooperative -cancel, honest mark_done/mark_failed statuses) удалён вместе с +cancel, honest mark_done/mark_failed/mark_banned statuses) удалён вместе с `app.services.scrape_pipeline` (#2397 Part E1) — эквивалентная regression-coverage -теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_{done, -blocked_failed}` (kit `run_domclick_city_sweep`). +теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_*` +(kit `run_domclick_city_sweep`). `app.services.scrapers.domclick` удалён (#2397 Part E2, 0 runtime-импортёров) — `_map_item` ретаргетирован на kit `scraper_kit.providers.domclick.serp.DomClickScraper` diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index 5dae8cb1..c38ffc0d 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -9,7 +9,7 @@ sweep). Здесь — остальные 7 sweep'ов (#2135 F2): City sweep'ы (приоритет — активны в проде): - run_yandex_city_sweep — combos SERP + save + price-history - run_cian_city_sweep — SERP + newbuilding_only-фильтр + save - - run_domclick_city_sweep — BFF citywide + честный статус (done / failed) + - run_domclick_city_sweep — BFF citywide + честный статус (done / failed / banned) Плюс: - run_avito_newbuilding_sweep — citywide novostroyka SERP + save Full load'ы (smoke — импорт + базовый прогон через on_bucket): @@ -388,7 +388,11 @@ async def test_cian_city_sweep_honest_empty_stays_done() -> None: async def _drive_domclick( - *, lots_n: int, blocked: bool, capture: dict[str, Any] | None = None + *, + lots_n: int, + blocked: bool, + fetch_errors: int = 0, + capture: dict[str, Any] | None = None, ) -> _DriveResult: """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...).""" recorder = _RunsRecorder() @@ -398,7 +402,7 @@ async def _drive_domclick( fetch_city=AsyncMock(return_value=lots), blocked=blocked, geo_filtered=0, - fetch_errors=0, + fetch_errors=fetch_errors, ) save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else []) if capture is not None: @@ -425,14 +429,45 @@ async def test_domclick_city_sweep_done() -> None: @pytest.mark.asyncio -async def test_domclick_city_sweep_blocked_failed() -> None: - """QRATOR-блок + 0 lots → mark_failed (честный статус #1968).""" +async def test_domclick_city_sweep_blocked_banned() -> None: + """QRATOR-блок + 0 lots → mark_banned (честный статус #1968, #2657).""" counters, calls = await _drive_domclick(lots_n=0, blocked=True) assert counters["lots_fetched"] == 0 assert counters["blocked"] == 1 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_blocked_with_lots_marks_banned() -> None: + """#2657: блок оборвал бакеты ПОСЛЕ части лотов → banned, не done. + + Прод-случай: 13 из 13 прогонов с blocked=1 уходили в done, потому что + honest-status требовал ещё и lots_fetched == 0. + """ + counters, calls = await _drive_domclick(lots_n=4, blocked=True) + assert counters["lots_fetched"] == 4 + assert counters["blocked"] == 1 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_fetch_errors_without_block_stays_failed() -> None: + """#2657 анти-оверрич: 0 лотов + fetch-ошибки, но БЕЗ блока → failed, не banned.""" + counters, calls = await _drive_domclick(lots_n=0, blocked=False, fetch_errors=2) + assert counters["blocked"] == 0 + assert counters["errors_count"] == 2 assert calls[-1][0] == "mark_failed" +@pytest.mark.asyncio +async def test_domclick_city_sweep_honest_empty_stays_done() -> None: + """#2657 анти-оверрич: честная пустота (0 лотов, ни блока, ни ошибок) → done.""" + counters, calls = await _drive_domclick(lots_n=0, blocked=False) + assert counters["lots_fetched"] == 0 + assert counters["blocked"] == 0 + assert calls[-1][0] == "mark_done" + + # ── Avito newbuilding sweep ─────────────────────────────────────────────────── diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 3150a503..57a17fc6 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -3702,8 +3702,10 @@ async def run_domclick_city_sweep( Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо прямых импортов app.* (см. scraper_kit.contracts). - ЧЕСТНЫЙ СТАТУС (#1968): если scraper сообщил QRATOR-блок И lots == 0 → - mark_failed. Иначе mark_done. + ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned + (независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты, + прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed. + Иначе mark_done. Возвращает DomClickCitySweepCounters. """ @@ -3797,19 +3799,43 @@ async def run_domclick_city_sweep( counters.pages_fetched = _num_fetches runs.update_heartbeat(db, run_id, counters.to_dict()) - # ── ЧЕСТНЫЙ СТАТУС (#1968) ──────────────────────────────────────────── - if counters.lots_fetched == 0 and (counters.blocked or counters.errors_count > 0): + # ── ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657) ─────────────────────────── + # Распознанный QRATOR-блок НИКОГДА не даёт done. Домклик тут структурно + # отличается от cian/yandex (#2625/#2642): там независимые anchor'ы, и + # провал одного среди успешных — не бан (анти-флап). Здесь anchor'ов нет, + # sweep линейный по ROOM_BUCKETS, и первый же блок делает `break` — + # оставшиеся бакеты не пробуются вовсе (fetch_city). То есть блок = прогон + # оборван, сколько бы лотов он ни успел взять до этого; на проде это дало + # 13 из 13 «успешных» прогонов с blocked=1 (39-464 лота вместо ~6300). + # banned (а не failed): это external constraint, не наш баг, и тот же + # статус, что #2642 дал cian/yandex — доступен как триггер ротации IP + # (#2611; сама ротация здесь не вызывается). Пометка прокси-пула живёт + # отдельно и раньше — fetcher.report_ban в serp.py (#2600 п.1). + if counters.blocked: logger.error( - "domclick-sweep run_id=%d: 0 listings with blocked=%d errors=%d " - "— marking failed", + "domclick-sweep run_id=%d: QRATOR block aborted remaining room buckets " + "(lots=%d, errors=%d) — marking banned (#2657)", + run_id, + counters.lots_fetched, + counters.errors_count, + ) + runs.mark_banned( + db, + run_id, + f"QRATOR block aborted sweep — {counters.lots_fetched} listings " + "collected before abort (#2657)", + counters.to_dict(), + ) + elif counters.lots_fetched == 0 and counters.errors_count > 0: + logger.error( + "domclick-sweep run_id=%d: 0 listings with errors=%d — marking failed", run_id, - counters.blocked, counters.errors_count, ) runs.mark_failed( db, run_id, - "QRATOR block or fetch errors — 0 listings", + "fetch errors — 0 listings", counters.to_dict(), ) else: From c232772e703335ef6e7ee251dfe2a84438f1e76f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:39:40 +0500 Subject: [PATCH 009/130] =?UTF-8?q?fix(tradein/cian):=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20bti=20=D0=B8=D0=B7=20offerData=20=E2=80=94?= =?UTF-8?q?=20BTI-=D0=BF=D0=B5=D1=80=D1=81=D0=B8=D1=81=D1=82=20=D0=B2=20ho?= =?UTF-8?q?uses=20=D0=BF=D0=B8=D1=81=D0=B0=D0=BB=20=D0=BD=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20(#2435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2435 (PR #2437) завёл запись BTI-полей дома через match_or_create_house, но на проде она не дала ни одной строки: 9361 дом, 0 с series_name/entrances/flat_count/ is_emergency/heat_supply_type/gas_supply_type/overlap_type — при 628 detail- обогащённых Cian-листингах за Jul 5-22 и 5188 домах, которых Cian вообще касался. Причина: bti читался только как соседний с defaultState ключ контейнера frontend-offer-card, а Cian отдаёт его ВНУТРИ defaultState — offerData.bti. extract_all_states() исправно возвращает 143 ключа, но bti среди них нет, поэтому bti_data всегда оставался None и весь write-path был мёртвым. Существующие тесты этого не ловили: они кормят bti_data прямо в save_detail_enrichment, минуя fetch_detail. Новый тест гоняет реальный сохранённый HTML (fixtures/cian_flat_330982715.html) через настоящий fetch_detail — без фикса краснеет. Старое место оставлено фоллбэком. --- .../tests/test_cian_bti_house_persist.py | 60 ++++++++++++++++++- .../src/scraper_kit/providers/cian/detail.py | 10 +++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py index e41a2790..1c44a0e6 100644 --- a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py +++ b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py @@ -21,9 +21,13 @@ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from contextlib import contextmanager -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock -from scraper_kit.providers.cian.detail import DetailEnrichment, save_detail_enrichment +from scraper_kit.providers.cian.detail import ( + DetailEnrichment, + fetch_detail, + save_detail_enrichment, +) from scraper_kit.providers.cian.valuation import CianValuationResult, _save_to_cache # ── Part 1: bti_data → houses (detail.py) ──────────────────────────────────── @@ -190,6 +194,58 @@ def test_bti_missing_listing_address_skips_resolve(): db.commit.assert_called_once() +# ── Part 1b: bti_data реально извлекается с живой страницы (регрессия) ─────── +# +# Тесты выше кормят `bti_data` напрямую в `save_detail_enrichment`, поэтому они +# оставались зелёными, пока `fetch_detail` на живых страницах НИКОГДА его не +# заполнял: bti читался только как соседний с `defaultState` ключ контейнера +# frontend-offer-card, а Cian отдаёт его ВНУТРИ defaultState — `offerData.bti`. +# Итог на проде: 628 detail-обогащённых Cian-листингов, 0 домов с BTI-полями. +# Здесь прогоняем настоящий сохранённый HTML через настоящий `fetch_detail`. + + +def _detail_fixture_html() -> str: + path = os.path.join(os.path.dirname(__file__), "fixtures", "cian_flat_330982715.html") + with open(path, encoding="utf-8") as fh: + return fh.read() + + +async def test_fetch_detail_extracts_bti_from_offer_data_on_real_page(): + """Реальная страница Cian: bti лежит в offerData → bti_data должен заполниться.""" + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=_detail_fixture_html()) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/330982715/", browser_fetcher=fetcher) + + assert result is not None + assert result.bti_data, "bti_data пуст — BTI-персист (#2435) в проде остаётся no-op" + # Поля, которые пишет _persist_cian_bti_house (см. Part 1). + assert result.bti_data["entrances"] == 3 + assert result.bti_data["flatCount"] == 24 + assert result.bti_data["isEmergency"] is False + assert result.bti_data["houseHeatSupplyType"] == "central" + assert result.bti_data["houseGasSupplyType"] == "central" + assert result.bti_data["houseOverlapType"] == "concrete" + + +async def test_fetch_detail_bti_legacy_sibling_location_still_read(): + """Фоллбэк: если Cian вернёт bti соседним ключом контейнера — читаем и оттуда.""" + html = ( + "" + ) + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=html) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/777/", browser_fetcher=fetcher) + + assert result is not None + assert result.bti_data == {"seriesName": "1-464", "flatCount": 58} + + # ── Part 2: valuation house_info/managementCompany → houses (valuation.py) ── _HOUSE_INFO_SAMPLE = [ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py index 1b9ec683..65edcdf4 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py @@ -4,7 +4,8 @@ Different from SERP (cian.py): - URL: https://ekb.cian.ru/sale/flat// or https://www.cian.ru/sale/flat// - MFE: 'frontend-offer-card' - State KEY: 'defaultState' (NOT 'initialState' — SERP uses initialState) -- Sister containers in same _cianConfig: bti, priceChanges, stats, agent, newObject +- Sister data (bti, priceChanges, stats, agent, newObject) живёт ВНУТРИ defaultState + (`offerData.*`), а не отдельными ключами _cianConfig рядом с defaultState Parses ~88 offer fields + sister data → DetailEnrichment dataclass. Stage 5 of CianScraper v1. @@ -176,7 +177,12 @@ async def fetch_detail( all_states = extract_all_states(html) offer_card_states = all_states.get("frontend-offer-card", {}) - bti_state = offer_card_states.get("bti") + # BTI лежит ВНУТРИ defaultState (`offerData.bti`), а не отдельным ключом рядом с + # `defaultState` в контейнере frontend-offer-card. Пока читали только второе место, + # `bti_data` всегда был None и #2435 писал в houses ноль строк (прод: 0 домов с + # series_name/entrances/... при 628 detail-обогащённых листингах). Соседний ключ + # оставлен фоллбэком — на случай, если Cian вернёт старую раскладку. + bti_state = offer_data.get("bti") or offer_card_states.get("bti") if bti_state: result.bti_data = bti_state.get("houseData") result.raw_sister_states["bti"] = bti_state From b88535425e2a91f4e4cadff3fb58c41bf8909c6f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:49:03 +0500 Subject: [PATCH 010/130] =?UTF-8?q?fix(tradein):=20=D0=B3=D0=B5=D0=B9?= =?UTF-8?q?=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B4=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B8=D1=8F=20=D0=BD=D0=B0=20=C2=AB=D0=BC=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B3=C2=BB=20=E2=80=94=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D1=84=D0=B0=D0=BA=D1=82=20=D0=BF=D0=B5=D0=B9=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=D0=B0=20=D0=BA=D0=B0=D0=BA=20=D1=80=D1=8B=D0=BD?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=BA=D1=82=20(#2?= =?UTF-8?q?666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /sales-vs-listings отдавал median_discount_pct без всякой проверки: после сегментного гарда #2660 по `%Космонавтов%` 2-комн. значение уехало с −11.9% на +36.4%, то есть пользователю написали бы «продали на 36% дороже, чем просили». Корень унаследованный — пейринг ДКП↔объявление идёт по улице без номера дома (ADR #721), так что на длинной улице в пару попадают квартиры разных ценовых классов. Пейринг здесь не чиним, перестаём показывать число, которому нельзя верить. Пороги подобраны по проду (симуляция эндпоинта на 238 реальных пользовательских запросах из trade_in_estimates, 128 дали хотя бы одну пару): - MIN_PAIRS = 10 — бутстрап по 12 плотным группам: p90 отклонения медианы подвыборки от полной 18.8 п.п. при k=5, 12.0 при k=10, 9.9 при k=15. Кривая ломается на 10; совпадает с уже принятым в продукте sell_time_sensitivity_min_n_lots. - Санитарный диапазон [−60%, +20%] — асимметричный. Сверху распределение разорвано (…+16.9, пусто, +33.7…+103.1), отсечка попадает в разрыв; ни один городской бакет asking_to_sold_ratios не даёт плюса вообще (max 0.9132). Снизу разрыва нет (у большого минуса есть механизм — занижение цены в ДКП), граница грубая «заведомо не рынок»: 2.5× худшего бакета (студии, −23.8%). Форма отказа — не пустота: новое поле median_discount_explanation по образцу confidence_explanation оценщика, фронт рендерит его вместо числа. Гаснет ровно строка «медианный торг»: сделки, медиана ₽/м², диапазон, linkage_rate_pct и per-pair discount_pct не трогаются. --- tradein-mvp/backend/app/api/v1/trade_in.py | 79 ++++++++++ tradein-mvp/backend/app/schemas/trade_in.py | 7 + .../backend/tests/test_sales_vs_listings.py | 142 ++++++++++++++++-- .../src/app/ui-preview/estimate/fixture.ts | 1 + .../components/trade-in/StreetDealsCard.tsx | 5 + .../src/components/trade-in/v2/mappers.ts | 14 +- tradein-mvp/frontend/src/types/trade-in.ts | 4 + 7 files changed, 235 insertions(+), 17 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 12e25b24..bd446ec4 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1821,6 +1821,50 @@ def get_street_deals( # ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ────────────── +# #2666 гейт правдоподобия на «медианный торг». Пейринг ДКП↔объявление идёт по +# УЛИЦЕ без номера дома (data_quality="street_only", ADR #721): на длинной улице +# сделка и объявление могут стоять в разных домах и разных ценовых классах, и +# тогда discount_pct — не торг, а разница между двумя чужими друг другу лотами. +# Гард #2660 (миграция 211) убрал предвзятые пары «вторичка ↔ новостройка» и тем +# самым сделал остаток артефактов ВИДНЫМ: по `%Космонавтов%` 2-комн. медиана +# уехала с −11.9% на +36.4%, т.е. пользователю написали бы «продали на 36% +# дороже, чем просили». Здесь не чиним пейринг (это ADR-уровень), а перестаём +# показывать число, которому нельзя верить. +# +# Пороги подобраны по проду 2026-08-05 (симуляция эндпоинта на 238 РЕАЛЬНЫХ +# пользовательских запросах из trade_in_estimates — тот же address/area/rooms, +# что уходил в виджет; 128 из них дали хотя бы одну пару): +# +# MIN_PAIRS = 10 — бутстрап по 12 «плотным» группам (n ≥ 60 пар): из полной +# выборки берём подвыборку размера k и смотрим, насколько медиана подвыборки +# отклоняется от полной. p90 |отклонения|: k=5 → 18.8 п.п., k=10 → 12.0, +# k=15 → 9.9, k=20 → 8.2. Кривая ломается ровно на 10 (5→10 даёт −6.8 п.п. +# шума, 10→15 уже только −2.1, а каждые +5 к порогу стоят ещё ~8-10% улиц). +# Совпадает с уже принятым в продукте порогом малой выборки +# settings.sell_time_sensitivity_min_n_lots = 10. +# +# SANE_MIN/MAX = [−60%, +20%] — асимметричны намеренно, у сторон разная природа: +# ВЕРХ. В наблюдаемом распределении 128 групп положительный хвост РАЗОРВАН: +# +11.1, +10.8, +16.9 — и дальше пусто до +33.7, +34.2, +34.6, +39.0, +52.5, +# +70.2, +81.5, +103.1. Отсечка +20% попадает в пустой промежуток, т.е. режет +# отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один +# городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132 +# = −8.7% торга), так что «продали на +20% дороже ask» уже вдвое дальше любого +# рыночно объяснимого плюса. +# НИЗ. Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо: +# у большого отрицательного торга есть механизм (занижение цены в ДКП), в +# отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»: +# худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза +# глубже. Режет 6 групп из 128 (−87 … −64). +# +# Цена гейта на проде: из 128 групп с парами число сохраняют 64 (50%), 59 (46%) +# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся: +# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно +# строка «медианный торг», и вместо неё уходит median_discount_explanation. +SALES_VS_LISTINGS_MIN_PAIRS = 10 +SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0 +SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0 + @router.get("/sales-vs-listings", response_model=SalesVsListingsResponse) def get_sales_vs_listings( @@ -1961,6 +2005,40 @@ def get_sales_vs_listings( discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None) median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None + # #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо + # отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь + # прочитает как поломку, а не как честность. + median_discount_explanation: str | None = None + if median_discount is not None: + if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS: + median_discount_explanation = ( + f"Медианный торг не показываем: пар «сделка ↔ объявление» всего " + f"{len(discounts)}, надёжная медиана начинается от " + f"{SALES_VS_LISTINGS_MIN_PAIRS}." + ) + elif not ( + SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT + <= median_discount + <= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT + ): + # Типографский минус (U+2212) — как в fmtDiscount на фронте. + shown = f"{median_discount:+.1f}".replace("-", "−") + median_discount_explanation = ( + f"Медианный торг не показываем: расчёт дал неправдоподобное значение " + f"({shown}%) — на протяжённой улице в пару могли попасть квартиры " + f"разных ценовых классов." + ) + if median_discount_explanation is not None: + logger.info( + "sales-vs-listings: median_discount gated street=%r rooms=%d " + "n_pairs=%d value=%+.2f%%", + street_name, + rooms, + len(discounts), + median_discount, + ) + median_discount = None + logger.info( "sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s", street_name, @@ -1979,6 +2057,7 @@ def get_sales_vs_listings( deals_with_listings=deals_with_listings, linkage_rate_pct=linkage_rate_pct, median_discount_pct=median_discount, + median_discount_explanation=median_discount_explanation, # street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) → # даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им. data_quality="street_only" if total_deals > 0 else "no_data", diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 3df09fe1..812b2a82 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -604,6 +604,13 @@ class SalesVsListingsResponse(BaseModel): deals_with_listings: int # сколько имеют связанный listing linkage_rate_pct: float # deals_with_listings / total_deals * 100 median_discount_pct: float | None # медиана по парам с listing + # #2666: None вместе с median_discount_pct=None означает «медианы просто нет» + # (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт + # правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги + # SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается. + # Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен + # текст «почему числа нет», иначе пустое место читается как поломка виджета. + median_discount_explanation: str | None = None data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3) pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 184c785d..40f5d4c5 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -8,6 +8,8 @@ Covers: - linkage_rate_pct computation. - median_discount_pct on subset с listing_id != None. - extract_street_name failure → returns empty response with street=None. + - #2666 гейт правдоподобия median_discount_pct: мало пар / значение вне + санитарного диапазона → числа нет, но есть median_discount_explanation. """ import os @@ -101,6 +103,14 @@ def _make_pair_row( } +def _rows_with_discounts(discounts: list[float]) -> list[dict]: + """N пар с заданными discount_pct (deal_id/listing_id уникальны).""" + return [ + _make_pair_row(deal_id=1000 + i, listing_id=2000 + i, discount_pct=d) + for i, d in enumerate(discounts) + ] + + def _override_db(trade_in_app: FastAPI, db_mock: MagicMock) -> None: from app.core.db import get_db @@ -198,7 +208,10 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 1 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 100.0 - assert data["median_discount_pct"] == -5.77 + # #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а + # вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия. + assert data["median_discount_pct"] is None + assert "1" in data["median_discount_explanation"] assert len(data["pairs"]) == 1 pair = data["pairs"][0] assert pair["deal_id"] == 1001 @@ -251,8 +264,9 @@ def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 2 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 50.0 - # median считается только по парам с discount_pct - assert data["median_discount_pct"] == -5.0 + # median считается только по парам с discount_pct — но одной пары мало, + # #2666 гейт её гасит (сам LEFT JOIN это не ломает). + assert data["median_discount_pct"] is None # Pair without listing pair_no_listing = next(p for p in data["pairs"] if p["deal_id"] == 1002) assert pair_no_listing["listing_id"] is None @@ -265,14 +279,16 @@ def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: - """Median считается через _percentile(0.5) только по парам c discount_pct.""" - # Discounts: [-10, -5, 0, 3, 7] → median = 0 + """Median считается через _percentile(0.5) только по парам c discount_pct. + + 11 пар (≥ MIN_PAIRS #2666) с рыночной медианой — число доходит до ответа, + объяснения нет. + """ + # Discounts: 11 значений, средний (индекс 5) = -17.0 → median = -17.0 fixture_rows = [ - _make_pair_row(deal_id=1, listing_id=11, discount_pct=-10.0), - _make_pair_row(deal_id=2, listing_id=12, discount_pct=-5.0), - _make_pair_row(deal_id=3, listing_id=13, discount_pct=0.0), - _make_pair_row(deal_id=4, listing_id=14, discount_pct=3.0), - _make_pair_row(deal_id=5, listing_id=15, discount_pct=7.0), + *_rows_with_discounts( + [-25.0, -23.0, -21.0, -20.0, -19.0, -17.0, -16.0, -15.0, -13.0, -11.0, -9.0] + ), # Сделка без listing — не учитывается в median. _make_pair_row( deal_id=6, @@ -295,10 +311,11 @@ def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: ) assert resp.status_code == 200 data = resp.json() - assert data["total_deals"] == 6 - assert data["deals_with_listings"] == 5 - assert round(data["linkage_rate_pct"], 1) == 83.3 - assert data["median_discount_pct"] == 0.0 + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 11 + assert round(data["linkage_rate_pct"], 1) == 91.7 + assert data["median_discount_pct"] == -17.0 + assert data["median_discount_explanation"] is None # ── Test: SQL function called with proper params ───────────────────────────── @@ -417,6 +434,7 @@ def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None: "deals_with_listings", "linkage_rate_pct", "median_discount_pct", + "median_discount_explanation", "pairs", } assert expected_keys.issubset(data.keys()) @@ -506,3 +524,99 @@ def test_sales_vs_listings_defaults(trade_in_app: FastAPI) -> None: assert data["window_days"] == 180 assert data["area_tolerance"] == 0.15 assert data["period_months"] == 24 + + +# ── Test: #2666 гейт правдоподобия median_discount_pct ─────────────────────── + + +def _get_sales(trade_in_app: FastAPI, rows: list[dict]) -> dict: + """GET /sales-vs-listings на фиксированном адресе, вернуть JSON.""" + _override_db(trade_in_app, _make_db_mock(rows)) + resp = TestClient(trade_in_app).get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "г. Екатеринбург, ул. Космонавтов, 50", + "area_m2": 50.0, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + return resp.json() + + +def test_median_discount_gated_when_too_few_pairs(trade_in_app: FastAPI) -> None: + """#2666: пар меньше MIN_PAIRS → числа нет, но есть объяснение почему. + + Прод-бутстрап (2026-08-05): на 9 парах p90 отклонения медианы подвыборки от + полной ≈ 12-19 п.п. — такое число нельзя показывать как «медианный торг». + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 9)) + assert data["deals_with_listings"] == 9 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + # Объяснение называет ФАКТИЧЕСКОЕ число пар и порог — иначе оно бесполезно. + assert "9" in data["median_discount_explanation"] + assert "10" in data["median_discount_explanation"] + + +def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> None: + """MIN_PAIRS включительно: ровно 10 пар — число ещё отдаётся.""" + data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 10)) + assert data["median_discount_pct"] == -12.0 + assert data["median_discount_explanation"] is None + + +def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) -> None: + """#2666: «продали на 36% дороже, чем просили» — артефакт пейринга по улице. + + Ровно кейс из issue (`%Космонавтов%` 2-комн., +36.4% после гарда #2660). + Пар достаточно, гасит именно санитарный диапазон. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) + assert data["deals_with_listings"] == 11 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + assert "36" in data["median_discount_explanation"] + + +def test_median_discount_gated_when_implausibly_negative(trade_in_app: FastAPI) -> None: + """Нижняя граница диапазона: −70% в 3 раза глубже худшего городского + asking→sold бакета (студии, ratio 0.7623 = −23.8%) — тоже не рынок.""" + data = _get_sales(trade_in_app, _rows_with_discounts([-70.0] * 11)) + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + + +def test_median_discount_kept_at_sane_range_boundaries(trade_in_app: FastAPI) -> None: + """Границы санитарного диапазона включительные: +20.0% и −60.0% проходят.""" + top = _get_sales(trade_in_app, _rows_with_discounts([20.0] * 11)) + assert top["median_discount_pct"] == 20.0 + assert top["median_discount_explanation"] is None + + bottom = _get_sales(trade_in_app, _rows_with_discounts([-60.0] * 11)) + assert bottom["median_discount_pct"] == -60.0 + assert bottom["median_discount_explanation"] is None + + +def test_median_discount_normal_case_unchanged(trade_in_app: FastAPI) -> None: + """Нормальный случай (пар хватает, значение рыночное) — число как прежде.""" + data = _get_sales( + trade_in_app, + _rows_with_discounts( + [-25.0, -23.0, -21.0, -20.0, -19.0, -17.0, -16.0, -15.0, -13.0, -11.0, -9.0] + ), + ) + assert data["median_discount_pct"] == -17.0 + assert data["median_discount_explanation"] is None + + +def test_median_discount_gate_leaves_pairs_and_linkage_untouched( + trade_in_app: FastAPI, +) -> None: + """Гейт гасит ТОЛЬКО сводную медиану: linkage_rate_pct и per-pair discount_pct + остаются — это наблюдаемые факты, а не оценка по улице.""" + data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) + assert data["median_discount_pct"] is None + assert data["linkage_rate_pct"] == 100.0 + assert len(data["pairs"]) == 11 + assert all(p["discount_pct"] == 36.4 for p in data["pairs"]) diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts index eadedb6a..e441325a 100644 --- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -380,6 +380,7 @@ export const FIXTURE_SALES: SalesVsListingsResponse = { deals_with_listings: 5, linkage_rate_pct: 55.6, median_discount_pct: -6.2, + median_discount_explanation: null, data_quality: "house_linked", pairs: [ { diff --git a/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx index 69091f28..7a866783 100644 --- a/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx @@ -114,6 +114,11 @@ export function StreetDealsCard({ estimate }: Props) { )} )} + {/* #2666: медиана не прошла гейт правдоподобия — показываем причину, + а не пустое место (тот же паттерн, что confidence_explanation). */} + {data.median_discount_explanation && ( +
{data.median_discount_explanation}
+ )} {data.data_quality === "street_only" && (
Данные по улице, не по конкретному дому: привязать сделки ДКП к diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 79fd9f1e..81e9ff94 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1524,9 +1524,17 @@ export function mapHistory( `медианный торг ${pct1(salesVsListings.median_discount_pct)}`, ); } - const note = - (noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") + - "Данные по улице, не по дому."; + // #2666: медиана не прошла гейт правдоподобия — отдельным предложением + // объясняем, почему числа нет (пустое место читается как поломка виджета). + const note = [ + noteParts.length > 0 ? `${noteParts.join(" · ")}.` : null, + salesVsListings?.median_discount_pct == null + ? salesVsListings?.median_discount_explanation + : null, + "Данные по улице, не по дому.", + ] + .filter(Boolean) + .join(" "); const dkpKpi = { count: streetDeals?.count != null ? String(streetDeals.count) : "—", diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index ed5c75f4..7db71758 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -405,6 +405,10 @@ export interface SalesVsListingsResponse { deals_with_listings: number; linkage_rate_pct: number; median_discount_pct: number | null; + // #2666: непустая строка = медиана посчиталась, но не прошла гейт правдоподобия + // (мало пар / значение вне санитарного диапазона) и намеренно не показывается. + // Рендерим ВМЕСТО числа — пустое место читается как поломка, а не как честность. + median_discount_explanation: string | null; // Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть // сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок. data_quality: "house_linked" | "street_only" | "no_data"; From 77ae08f2074b151d681ef87edd27382672232f3f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 00:16:13 +0500 Subject: [PATCH 011/130] =?UTF-8?q?fix(tradein):=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=20=D0=B3=D0=B5=D0=B9=D1=82=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D0=B0=D0=BA=D1=82=20=D0=BF=D1=80=D0=BE=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BA=D1=83=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D0=B5=D1=89=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B4=D1=91=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?(#2666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правки по ревью PR #2671. Текст «надёжная медиана начинается от 10» обещал то, чего мы гарантировать не можем: пары — псевдореплики (одно объявление переиспользуется на многих сделках, на живом кейсе Космонавтов 2-комн. 42 пары стоят на 2 различных объявлениях), и 10 пар надёжности не дают. Теперь отказ сообщает факт: сколько пар есть и что на такой выборке медиана гуляет на десятки п.п. Формулировка диапазонной ветки укорочена: она дублировала street_only- дисклеймер, который идёт следующим блоком. Проверено скриншотом отрендеренной карточки — две формулировки подряд читались как стена текста; теперь три однострочных хинта, на 820px — по две строки, переполнения нет. В шапку секции добавлен потолок гейта, найденный ревью: бутстрап пересэмплировал ПАРЫ, т.е. мерил дисперсию со стороны сделок, а доминирует дисперсия со стороны ОБЪЯВЛЕНИЙ (джекнайф p90 17.3 п.п., max 63.8); 22 из 64 переживших групп стоят на одном объявлении. Плюс нижняя граница оказалась слишком мягкой, а не строгой: 26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%. Оба пункта — отдельная задача, здесь только зафиксированы, чтобы порог не перечитали как гарантию. Тесты: пустое утверждение "1" in explanation (всегда истинно из-за "10") заменено на «всего 1 —». Добавлены два недостающих — отсутствие пар со скидкой даёт explanation=None, и порядок проверок (3 пары по +80% отчитываются «мало пар», а не «вне диапазона»). --- tradein-mvp/backend/app/api/v1/trade_in.py | 34 +++++++++++-- .../backend/tests/test_sales_vs_listings.py | 49 +++++++++++++++++-- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index bd446ec4..80a7cc54 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1861,6 +1861,26 @@ def get_street_deals( # теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся: # сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно # строка «медианный торг», и вместо неё уходит median_discount_explanation. +# +# ПОТОЛОК ГЕЙТА (ревью #2671, знать до следующей правки — здесь НЕ чинится): +# 1. Пары — псевдореплики. DISTINCT ON берёт по объявлению на сделку, но ОДНО +# объявление переиспользуется на многих сделках: медиана по группам — 18 +# сделок на одно различное объявление, а на живом кейсе из issue +# (Космонавтов 2-комн., 50 м²) 42 пары стоят на 2 РАЗЛИЧНЫХ объявлениях. +# Бутстрап выше пересэмплировал ПАРЫ, т.е. мерил дисперсию со стороны +# сделок; доминирует дисперсия со стороны ОБЪЯВЛЕНИЙ — джекнайф по +# объявлениям даёт p90 17.3 п.п. и max 63.8 п.п., и MIN_PAIRS против неё +# бессилен. Из 64 переживших групп 22 (34%) стоят на ОДНОМ объявлении. +# Настоящий рычаг — считать различные объявления (при «пар ≥ 10 И +# объявлений ≥ 2» проходят 42 из 128); заведено отдельной задачей. +# Поэтому MIN_PAIRS — пол, а не гарантия: снижать бессмысленно, повышать +# тоже (вернувшиеся/оставшиеся группы всё равно на одном-двух объявлениях). +# 2. Нижняя граница слишком МЯГКАЯ, а не слишком строгая, как думалось при +# её выборе: из 64 показываемых чисел 26 (41%) лежат ниже −23.8% (худший +# объяснимый рынком бакет), самое глубокое показываемое — −58.5%. Мы гасим +# «+34%» и показываем «−58.5%» из того же артефакта; асимметрия работает +# против нас — абсурдный плюс сам себя опровергает, абсурдный минус +# выглядит правдоподобно. Ужесточение — та же отдельная задача. SALES_VS_LISTINGS_MIN_PAIRS = 10 SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0 SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0 @@ -2011,10 +2031,13 @@ def get_sales_vs_listings( median_discount_explanation: str | None = None if median_discount is not None: if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS: + # Формулировка — ФАКТ про выборку, а не обещание надёжности выше + # порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше — + # пары псевдореплики), обещать «от 10 надёжно» мы не вправе. median_discount_explanation = ( f"Медианный торг не показываем: пар «сделка ↔ объявление» всего " - f"{len(discounts)}, надёжная медиана начинается от " - f"{SALES_VS_LISTINGS_MIN_PAIRS}." + f"{len(discounts)} — на такой выборке медиана гуляет на десятки " + f"процентных пунктов." ) elif not ( SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT @@ -2023,10 +2046,13 @@ def get_sales_vs_listings( ): # Типографский минус (U+2212) — как в fmtDiscount на фронте. shown = f"{median_discount:+.1f}".replace("-", "−") + # Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно + # следующим блоком это говорит street_only-дисклеймер (карточка) / + # хвост note (v2-mappers). Проверено скриншотом — две формулировки + # подряд читались как стена текста. median_discount_explanation = ( f"Медианный торг не показываем: расчёт дал неправдоподобное значение " - f"({shown}%) — на протяжённой улице в пару могли попасть квартиры " - f"разных ценовых классов." + f"({shown}%) — такого торга на рынке не бывает." ) if median_discount_explanation is not None: logger.info( diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 40f5d4c5..940b96ab 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -211,7 +211,8 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: # #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а # вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия. assert data["median_discount_pct"] is None - assert "1" in data["median_discount_explanation"] + # «всего 1 —» целиком: голое "1" было бы всегда истинно (подстрока "10"). + assert "всего 1 —" in data["median_discount_explanation"] assert len(data["pairs"]) == 1 pair = data["pairs"][0] assert pair["deal_id"] == 1001 @@ -553,10 +554,10 @@ def test_median_discount_gated_when_too_few_pairs(trade_in_app: FastAPI) -> None data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 9)) assert data["deals_with_listings"] == 9 assert data["median_discount_pct"] is None - assert data["median_discount_explanation"] - # Объяснение называет ФАКТИЧЕСКОЕ число пар и порог — иначе оно бесполезно. - assert "9" in data["median_discount_explanation"] - assert "10" in data["median_discount_explanation"] + # Объяснение называет ФАКТИЧЕСКОЕ число пар — иначе оно бесполезно. И НЕ + # обещает надёжность выше порога: 10 пар тоже не гарантия (ревью #2671). + assert "всего 9 —" in data["median_discount_explanation"] + assert "надёжн" not in data["median_discount_explanation"] def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> None: @@ -610,6 +611,44 @@ def test_median_discount_normal_case_unchanged(trade_in_app: FastAPI) -> None: assert data["median_discount_explanation"] is None +def test_median_discount_explanation_absent_when_no_pairs_at_all( + trade_in_app: FastAPI, +) -> None: + """Сделки есть, но ни одной пары → медианы просто НЕТ, объяснять нечего. + + Отличать «не посчиталось» от «посчиталось и погашено гейтом» обязан фронт: + он рендерит объяснение вместо числа, и текст «медиана гуляет» на улице без + единого объявления был бы враньём. + """ + rows = [ + _make_pair_row( + deal_id=1000 + i, + listing_id=None, + listing_price_rub=None, + discount_pct=None, + ) + for i in range(12) + ] + data = _get_sales(trade_in_app, rows) + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 0 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] is None + + +def test_too_few_pairs_reported_before_out_of_range(trade_in_app: FastAPI) -> None: + """Порядок проверок: 3 пары по +80% — причина «мало пар», НЕ «вне диапазона». + + Обе проверки сработали бы, но «пар всего 3» информативнее и точнее: при + такой выборке значение вообще не заслуживает разбора на правдоподобность. + Тест закрепляет порядок — перестановка условий деградирует объяснение. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([80.0] * 3)) + assert data["median_discount_pct"] is None + assert "всего 3 —" in data["median_discount_explanation"] + assert "неправдоподобное" not in data["median_discount_explanation"] + + def test_median_discount_gate_leaves_pairs_and_linkage_untouched( trade_in_app: FastAPI, ) -> None: From 0815319e1cdf0ee916bc1680a763d3108966586a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 00:29:49 +0500 Subject: [PATCH 012/130] =?UTF-8?q?fix(tradein/imv):=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=BE=D0=BD=D1=82=20=D0=B8=20=D1=82=D0=B8=D0=BF=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=B0=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три дефекта в house_imv_backfill, найденные системным поиском (эпик #2674/#2673). 1. Тип ремонта был захардкожен литералом 'cosmetic' — все 2685 запросов ушли как «косметический ремонт», хотя мода repair_state по объявлениям тех же домов другая: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 (косметика лишь 36%). Теперь renovation_type берётся из mode(repair_state) в том же агрегате, что уже считает медианы комнат/площади/этажа, и проходит через существующий estimator._IMV_REPAIR_MAP (ленивый импорт — estimator тянет scraper_adapters, а тот импортирует этот модуль). Второго словаря не заводим. Неизвестный ремонт (498 домов из 2685) остаётся 'cosmetic': это середина порядковой шкалы required < cosmetic < euro < designer, а не край, системного сдвига в одну сторону не даёт. 2. Неизвестный тип дома молча становился 'panel' — и когда типа нет вовсе, и когда он есть, но не совпал со словарём. Панель почти самый дешёвый класс (медиана по нашим же 2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k руб/м2), то есть дефолт систематически занижал. На проде так уехали 363 дома совсем без типа и 75 домов с camelCase-типом из Циана (56 из них monolithBrick — минус 11.7% против monolithic). Теперь сырое значение прогоняется через общий scraper_kit.house_type_normalizer.normalize_house_type (знает monolithBrick / gasSilicateBlock / aerocreteBlock / stalin и SCREAMING-вокабуляр Яндекса), дефолт 'panel' убран: тип не распознан → house_type=None → дом помечается no_params ('unknown house_type') и запрос к площадке не тратится. 'other' и 'wireframe' намеренно НЕ маппятся — честного соответствия у них нет. 3. Прогон не умел падать: 31 прогон подряд с saved=0 и ~35 ошибками из 50 помечен 'done'. Тот же класс, что #2670/#2657 — успех определялся как «не поймали известное исключение». Теперь saved=0 при errors>0 → mark_failed. Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) остаётся done. Балкон/лоджия оставлены константами намеренно: покрытие listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и колонки противоречат друг другу (по has_balcony «есть» у 62%, а по balcony_loggia самый частый случай — loggia 5650 против balcony 2794). Мода по одному-двум объявлениям на таком покрытии — шум, а не данные. Причина, по которой бэкфилл не сохранил НИ ОДНОЙ оценки за 34 дня, — вне этого модуля и здесь не чинится (детали и числа в описании PR): 1240 домов легли на отказе браузерного сайдкара «нет прокси» (гейт #2616, 05.07-02.08), а после возврата прокси 05.08 — 23 на Page.evaluate «Execution context was destroyed» в tradein-browser и 12 на 403 Авито. Refs #2674 --- .../app/services/house_imv_backfill.py | 72 +++++- .../backend/app/services/product_handlers.py | 32 ++- .../backend/tests/test_backfill_wave2.py | 17 +- .../tests/test_house_imv_params_honesty.py | 206 ++++++++++++++++++ 4 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_house_imv_params_honesty.py diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index 38bdd005..cc172e1c 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -30,6 +30,7 @@ from dataclasses import dataclass, field from typing import Literal from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.house_type_normalizer import normalize_house_type # #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска, # что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex @@ -65,22 +66,63 @@ _HEARTBEAT_EVERY_N_HOUSES = 5 # ── house_type normalisation ───────────────────────────────────────────────── +# Ключи — КАНОНИЧНЫЕ значения listings.house_type (после normalize_house_type), +# значения — вокабуляр Avito IMV. _HOUSE_TYPE_TO_IMV: dict[str, str] = { "panel": "panel", "brick": "brick", "monolith": "monolithic", - "monolithic": "monolithic", "monolith_brick": "monolithic", # Avito API не принимает гибриды "block": "block", "wood": "wood", } -_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ -def _map_house_type(raw: str | None) -> str: - if not raw: - return _HOUSE_TYPE_DEFAULT - return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT) +def _map_house_type(raw: str | None) -> str | None: + """Наш house_type → вокабуляр Avito IMV. None = тип неизвестен, запрос не шлём. + + Сырое значение сначала прогоняем через общий normalize_house_type (#2007): он + знает camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / + aerocreteBlock / stalin / ...) и SCREAMING-вокабуляр Яндекса, а нераспознанное + ('other', 'wireframe', пустое) схлопывает в None. Приведения к нижнему регистру + тут мало: ключ канона пишется через подчёркивание (monolith_brick), поэтому + 'monolithbrick' в словарь не попадал. + + #2674: раньше здесь стоял дефолт 'panel' — и когда типа нет вовсе, и когда он + есть, но не распознан. Панель — почти самый дешёвый класс (медиана по нашим же + 2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k + ₽/м²), то есть дефолт систематически ЗАНИЖАЛ оценку: на проде 363 дома совсем + без типа + 75 домов с camelCase-типом (56 из них monolithBrick, −11.7% к + monolithic) уехали как панель. Теперь неизвестный тип → None → дом помечается + и запрос к площадке не тратится (см. _process_one_house). + """ + canon = normalize_house_type(raw) + if canon is None: + return None + return _HOUSE_TYPE_TO_IMV.get(canon) + + +def _map_renovation_type(repair_state: str | None) -> str: + """listings.repair_state → renovation_type вокабуляра Avito IMV. + + Переиспользуем _IMV_REPAIR_MAP эстиматора — единственный источник правды для + этого соответствия (needs_repair→required / standard→cosmetic / good→euro / + excellent→designer). Импорт ленивый: estimator тянет scraper_adapters, а тот + импортирует этот модуль (circular — см. блок импортов выше). + + #2674: раньше здесь стоял литерал 'cosmetic' — все 2685 запросов ушли как + «косметический ремонт», хотя мода по объявлениям этих же домов совсем другая + (standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 — косметика + лишь 36%). + + Неизвестный ремонт (498 домов из 2685 — ни одного объявления с repair_state) + ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: это середина + порядковой шкалы (required < cosmetic < euro < designer), а не её край, + поэтому системного сдвига цены в одну сторону не даёт. + """ + from app.services.estimator import _IMV_REPAIR_MAP # lazy — см. import-блок + + return _IMV_REPAIR_MAP.get(repair_state) or "cosmetic" # ── Region bbox prefix для Avito geocoder ──────────────────────────────────── @@ -135,7 +177,8 @@ def pick_lot_params(db: Session, house_id: int) -> dict: AS integer) AS floor, CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors) AS integer) AS total_floors, - mode() WITHIN GROUP (ORDER BY house_type) AS house_type + mode() WITHIN GROUP (ORDER BY house_type) AS house_type, + mode() WITHIN GROUP (ORDER BY repair_state) AS repair_state FROM listings WHERE house_id_fk = :hid AND rooms IS NOT NULL @@ -173,7 +216,12 @@ def pick_lot_params(db: Session, house_id: int) -> dict: "floor": floor, "floor_at_home": floor_at_home, "house_type": _map_house_type(row["house_type"] or (house and house["house_type"])), - "renovation_type": "cosmetic", + "renovation_type": _map_renovation_type(row["repair_state"]), + # has_balcony/has_loggia остаются константами намеренно (#2674): покрытие + # listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и две колонки + # противоречат друг другу (по has_balcony «есть» у 62%, а по + # balcony_loggia самый частый случай — loggia 5650 против balcony 2794). + # Мода по одному-двум объявлениям на таком покрытии — шум, а не данные. "has_balcony": True, "has_loggia": False, } @@ -652,6 +700,14 @@ async def _process_one_house( _mark_status(db, hid, "no_params", "no listings with rooms+area") return "no_params" + # #2674: тип дома неизвестен (нет ни в объявлениях, ни в houses — либо + # вокабуляр не распознан). Раньше такой дом молча уезжал как 'panel' и + # занижал оценку. Лучше не тратить запрос и честно пометить дом — тот же + # путь, что и при отсутствии комнат/площади. + if params["house_type"] is None: + _mark_status(db, hid, "no_params", "unknown house_type") + return "no_params" + address = house.get("address") or house.get("full_address") if not address: _mark_status(db, hid, "no_address", "house.address is NULL") diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index ffdaf553..1f484ac4 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -401,17 +401,27 @@ async def _job_house_imv_backfill( only_status=only_status, heartbeat=_heartbeat, ) - ctx.runs.mark_done( - db, - run_id, - { - "checked": result.checked, - "saved": result.saved, - "skipped": result.skipped, - "errors": result.errors, - "duration_sec": int(result.duration_sec), - }, - ) + counters = { + "checked": result.checked, + "saved": result.saved, + "skipped": result.skipped, + "errors": result.errors, + "duration_sec": int(result.duration_sec), + } + # Честный статус (#2674, тот же класс, что #2670/#2657): успех — это + # «сделали то, что собирались», а не «не поймали известное исключение». + # На проде так ушли в done 31 прогон подряд: saved=0 при errors≈35 из 50. + # Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) — честная + # пустота, она по-прежнему done. + if result.saved == 0 and result.errors > 0: + ctx.runs.mark_failed( + db, + run_id, + f"saved=0 при errors={result.errors} (checked={result.checked})", + counters, + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception as exc: logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id) try: diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 8aaefd7b..5ac78324 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -367,23 +367,28 @@ class TestHouseTypeMap: assert _map_house_type("brick") == "brick" assert _map_house_type("monolith") == "monolithic" assert _map_house_type("monolith_brick") == "monolithic" - assert _map_house_type("monolithic") == "monolithic" assert _map_house_type("block") == "block" assert _map_house_type("wood") == "wood" - def test_unknown_falls_back_to_panel(self): + def test_unknown_is_none_not_panel(self): + """#2674: дефолт 'panel' убран — он занижал оценку. Неизвестное → None. + + Полное покрытие camelCase-вокабуляра и skip-пути: + tests/test_house_imv_params_honesty.py. + """ from app.services.house_imv_backfill import _map_house_type - assert _map_house_type("unknown_type") == "panel" - assert _map_house_type(None) == "panel" - assert _map_house_type("") == "panel" + assert _map_house_type("unknown_type") is None + assert _map_house_type(None) is None + assert _map_house_type("") is None def test_case_insensitive(self): from app.services.house_imv_backfill import _map_house_type assert _map_house_type("PANEL") == "panel" assert _map_house_type("Brick") == "brick" - assert _map_house_type("MONOLITH_BRICK") == "monolithic" + # SCREAMING-вокабуляр Яндекса (MONOLIT_BRICK, одна «т») — реальный токен. + assert _map_house_type("MONOLIT_BRICK") == "monolithic" class TestRegionPrefix: diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py new file mode 100644 index 00000000..224d9566 --- /dev/null +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -0,0 +1,206 @@ +"""#2674 — домовая оценка Авито перестаёт врать про ремонт и тип дома. + +Покрывает три дефекта из эпика: + 1. renovation_type берётся из моды listings.repair_state и проходит через + существующий estimator._IMV_REPAIR_MAP (был захардкожен литерал 'cosmetic': + 2685 из 2685 запросов ушли как «косметический ремонт»). + 2. Неизвестный тип дома НЕ уезжает дефолтом 'panel' (самый дешёвый класс → + системное занижение), а помечает дом и экономит запрос. Отдельно — + camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / stalin) + распознаётся, к нижнему регистру он не приводится. + 3. Прогон с saved=0 и ненулевыми errors не помечается 'done'. + +БД и сеть замоканы — реального Postgres/Авито не нужно. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import house_imv_backfill as hib +from app.services.product_handlers import _job_house_imv_backfill + +# ── (1) renovation_type из данных через существующий маппинг ────────────────── + + +def _db_for_pick(listing_row: dict[str, Any], house_row: dict[str, Any] | None) -> MagicMock: + """MagicMock-Session: два .mappings().first() подряд (listings-агрегат, houses).""" + db = MagicMock() + db.execute.return_value.mappings.return_value.first.side_effect = [listing_row, house_row] + return db + + +def _listing_row(**over: Any) -> dict[str, Any]: + base: dict[str, Any] = { + "rooms": 2, + "area_m2": 52.0, + "floor": 3, + "total_floors": 10, + "house_type": "panel", + "repair_state": None, + } + base.update(over) + return base + + +@pytest.mark.parametrize( + ("repair_state", "expected"), + [ + ("needs_repair", "required"), + ("standard", "cosmetic"), + ("good", "euro"), + ("excellent", "designer"), + ], +) +def test_renovation_type_comes_from_listings_via_estimator_map( + repair_state: str, expected: str +) -> None: + """Мода repair_state → renovation_type ровно по estimator._IMV_REPAIR_MAP.""" + from app.services.estimator import _IMV_REPAIR_MAP + + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=repair_state), None), 1) + + assert params["renovation_type"] == expected + # Не второй словарь: значение обязано совпадать с источником правды. + assert params["renovation_type"] == _IMV_REPAIR_MAP[repair_state] + + +def test_renovation_type_not_hardcoded_cosmetic() -> None: + """Regression #2674: 'good' больше не превращается в 'cosmetic'.""" + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state="good"), None), 1) + assert params["renovation_type"] != "cosmetic" + + +def test_unknown_repair_state_stays_cosmetic() -> None: + """Анти-оверрич: ремонт неизвестен → середина шкалы 'cosmetic', дом не теряем.""" + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=None), None), 1) + assert params["renovation_type"] == "cosmetic" + assert params["house_type"] == "panel" # дом всё ещё пригоден к запросу + + +# ── (2) тип дома: неизвестный не врёт, camelCase распознаётся ───────────────── + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + # camelCase из Циана — нижним регистром НЕ лечится (ключ канона через '_'). + ("monolithBrick", "monolithic"), + ("gasSilicateBlock", "block"), + ("aerocreteBlock", "block"), + ("stalin", "brick"), + # каноничные значения продолжают работать + ("panel", "panel"), + ("monolith", "monolithic"), + ("monolith_brick", "monolithic"), + ], +) +def test_map_house_type_recognises_camel_case(raw: str, expected: str) -> None: + assert hib._map_house_type(raw) == expected + + +@pytest.mark.parametrize("raw", [None, "", "other", "wireframe", "какая-то дичь"]) +def test_map_house_type_unknown_is_none_not_panel(raw: str | None) -> None: + """Regression #2674: нет типа / не распознан → None, а НЕ дефолт 'panel'.""" + assert hib._map_house_type(raw) is None + + +def test_pick_lot_params_unknown_house_type_yields_none() -> None: + """Типа нет ни в listings, ни в houses → house_type=None (не 'panel').""" + db = _db_for_pick(_listing_row(house_type=None), {"house_type": None, "total_floors": 9}) + assert hib.pick_lot_params(db, 1)["house_type"] is None + + +@pytest.mark.asyncio +async def test_unknown_house_type_skips_request_and_marks_house() -> None: + """Неизвестный тип → запрос к площадке НЕ уходит, дом помечен no_params.""" + params = { + "rooms": 2, + "area_m2": 52.0, + "floor": 3, + "floor_at_home": 10, + "house_type": None, + "renovation_type": "cosmetic", + "has_balcony": True, + "has_loggia": False, + } + houses = [{"id": 11, "address": "ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}] + db = MagicMock() + + with ( + patch.object(hib, "pick_lot_params", return_value=params), + patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval, + patch.object(hib, "_mark_status") as mock_mark, + ): + db.execute.return_value.mappings.return_value.all.return_value = houses + result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0) + + mock_eval.assert_not_called() + mock_mark.assert_called_once_with(db, 11, "no_params", "unknown house_type") + assert result.skipped == 1 + assert result.saved == 0 + + +# ── (3) прогон с нулём сохранённых и ошибками не «успешен» ──────────────────── + + +class _RunsRecorder: + """Duck-typed ctx.runs: пишет, чем закончился прогон.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + return None + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.calls.append(("mark_done", counters)) + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.calls.append(("mark_failed", counters)) + + +async def _drive_job(*, saved: int, errors: int, skipped: int = 0) -> list[tuple[str, dict]]: + runs = _RunsRecorder() + enrichment = MagicMock() + enrichment.house_imv_backfill = AsyncMock( + return_value=hib.HouseIMVBackfillResult( + checked=saved + errors + skipped, + saved=saved, + skipped=skipped, + errors=errors, + duration_sec=1.0, + ) + ) + ctx = MagicMock(runs=runs, enrichment=enrichment) + await _job_house_imv_backfill(MagicMock(), 1, {}, ctx) + return runs.calls + + +@pytest.mark.asyncio +async def test_zero_saved_with_errors_is_not_done() -> None: + """Прод-случай: 31 прогон подряд saved=0 / errors≈35 из 50 уходил в 'done'.""" + calls = await _drive_job(saved=0, errors=35, skipped=15) + assert calls[-1][0] == "mark_failed" + assert calls[-1][1]["saved"] == 0 + assert calls[-1][1]["errors"] == 35 + + +@pytest.mark.asyncio +async def test_honest_empty_stays_done() -> None: + """Анти-оверрич: ноль сохранённых без ошибок (всё в skipped) — честная пустота.""" + calls = await _drive_job(saved=0, errors=0, skipped=50) + assert calls[-1][0] == "mark_done" + + +@pytest.mark.asyncio +async def test_partial_success_stays_done() -> None: + """Анти-оверрич: что-то сохранили — прогон успешен, даже если были ошибки.""" + calls = await _drive_job(saved=3, errors=7) + assert calls[-1][0] == "mark_done" From 4b4ab8b34c5fbd52183a84e5279445871af2dd18 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 00:58:11 +0500 Subject: [PATCH 013/130] =?UTF-8?q?fix(tradein/imv):=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=20total=5Fseen/new=5Fcount=20+=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=20=D0=B4=D1=80=D0=B5=D0=B9=D1=84=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=BE=D0=BD=D1=82=D0=B0=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По ревью PR #2675. 1. counters прогона не заполняли выделенные колонки. _column_counts (scrape_runs.py) берёт total_seen из ключей total_seen|lots_fetched и new_count из new_count|lots_inserted — ни одного из них в дикте не было, поэтому все 39 прогонов этого source лежат в БД с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд done с нулевым результатом» (#2625): даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы ложной тревогой про капчу. Добавлены total_seen=checked и new_count=saved. Трейд-офф назван в комментарии: на исчерпанной очереди checked=0 три дня подряд тоже даст алерт — но пустая очередь при ежедневном расписании это и правда сигнал. 2. _map_renovation_type молча схлопывал в 'cosmetic' любое незнакомое непустое значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но дрейф вокабуляра реален (70950 строк listings с пустым нормализованным ремонтом). Добавлен logger.debug на случай «непустое, но не в карте» — паритет с house_type_normalizer, который такой лог уже пишет. 3. Обоснование дефолта 'cosmetic' в докстринге заменено на более сильное по данным: это одновременно МОДА и МЕДИАННАЯ категория популяции (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка, а не просто «не край шкалы». Там же названа асимметрия: поштучный путь эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит — решение осознанное (иначе теряем ещё ~32% домов очереди), чтобы следующий читатель не принял это за недосмотр. Refs #2674 --- .../app/services/house_imv_backfill.py | 21 +++++++++++++++---- .../backend/app/services/product_handlers.py | 11 ++++++++++ .../tests/test_house_imv_params_honesty.py | 14 +++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index cc172e1c..80b90a02 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -116,13 +116,26 @@ def _map_renovation_type(repair_state: str | None) -> str: лишь 36%). Неизвестный ремонт (498 домов из 2685 — ни одного объявления с repair_state) - ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: это середина - порядковой шкалы (required < cosmetic < euro < designer), а не её край, - поэтому системного сдвига цены в одну сторону не даёт. + ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: 'cosmetic' + (=standard) — это одновременно МОДА и МЕДИАННАЯ категория популяции + (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно + needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка. + У типа дома такой догадки нет: 'panel' — почти край шкалы, а не её середина. + + Асимметрия осознанная, а не недосмотр: поштучный путь эстиматора при + неизвестном ремонте IMV вообще не зовёт (estimator.py, `imv_renovation is not + None`), а домовой дефолтит — иначе теряем ещё ~32% домов очереди поверх тех, + что уже отсекает неизвестный тип дома. """ from app.services.estimator import _IMV_REPAIR_MAP # lazy — см. import-блок - return _IMV_REPAIR_MAP.get(repair_state) or "cosmetic" + mapped = _IMV_REPAIR_MAP.get(repair_state) + if mapped is None and repair_state: + # Непустое, но незнакомое значение — признак дрейфа вокабуляра на ингесте + # (сырых repair-значений в listings больше, чем нормализованных). Паритет + # с house_type_normalizer, который такой случай уже логирует. + logger.debug("house_imv: unmapped repair_state %r — падаем в 'cosmetic'", repair_state) + return mapped or "cosmetic" # ── Region bbox prefix для Avito geocoder ──────────────────────────────────── diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 1f484ac4..726ce48a 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -407,6 +407,17 @@ async def _job_house_imv_backfill( "skipped": result.skipped, "errors": result.errors, "duration_sec": int(result.duration_sec), + # #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из + # ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного + # из них тут не было, поэтому все 39 прогонов этого source лежат в БД + # с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд + # done с нулевым результатом» (#2625) — то есть даже идеальный прогон + # с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы + # ложной тревогой про капчу. + # Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст + # алерт — но пустая очередь при ежедневном расписании это и правда сигнал. + "total_seen": result.checked, + "new_count": result.saved, } # Честный статус (#2674, тот же класс, что #2670/#2657): успех — это # «сделали то, что собирались», а не «не поймали известное исключение». diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py index 224d9566..c36aa891 100644 --- a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -204,3 +204,17 @@ async def test_partial_success_stays_done() -> None: """Анти-оверрич: что-то сохранили — прогон успешен, даже если были ошибки.""" calls = await _drive_job(saved=3, errors=7) assert calls[-1][0] == "mark_done" + + +@pytest.mark.asyncio +async def test_counters_feed_total_seen_and_new_count() -> None: + """#2674: без этих ключей _column_counts оставляет колонку total_seen=0, + + и алерт «3 подряд done с нулевым результатом» (#2625) выстрелил бы даже на + полностью успешном прогоне. На проде так лежат все 39 прогонов source'а. + """ + from app.services.scrape_runs import _column_counts + + calls = await _drive_job(saved=50, errors=0) + counters = calls[-1][1] + assert _column_counts(counters) == (50, 50) From 15ca70ad709384820207e017ae1abaa6eb9c78a9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 01:49:54 +0500 Subject: [PATCH 014/130] =?UTF-8?q?fix(tradein/deploy):=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BF=D0=B5=D1=80=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20=D1=81=20=D0=B1=D1=8D=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=BE=D0=BC=20+=20=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D1=8F=20(#2679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Почему: `tradein-backend`, `tradein-scraper` и `tradein-tgbot` — один образ gendesign-tradein-backend, но пересоздание скрапера было привязано к allowlist'у путей («файлы, которые исполняет планировщик»). Список перечислял только то, что вспомнили: 2026-07-02 (#2188) на нём погорел fias-dedup — починили ДОБАВЛЕНИЕМ путей; 2026-08-05 (#2675) тот же механизм выстрелил снова на house_imv_backfill.py / product_handlers.py. За июнь-август 48% (193 из 402) backend-мержей не попадали ни в один путь списка. Что сделано вместо очередного пополнения списка: - фильтр `scraper` удалён; признак пересоздания = `if:` джобы build-backend (backend || infra || workflow_dispatch), т.е. «образ мог пересобраться»; - добавлена сверка image ID backend/scraper/tgbot после health-checks: при расхождении деплой падает ДО записи .tradein-deployed-sha, а не отчитывается успехом. Следующий прогон возьмёт ту же базу и пересоберёт накопленное. «Phase 0»-компромисс (infra не трогает скрапер, чтобы не убить многочасовой прогон) снят: с #1951 перед recreate'ом идёт graceful drain + startup-reap, а `compose up -d` на неизменившемся образе — no-op. Цена по факту (43 прод-деплоя 31.07-05.08): деплой со скрапером median 113s / mean 146s против 76s / 74s без него; средний деплой вырастет примерно на 40s, худший случай — до +5 мин (потолок drain'а при длинном full-load'е). Refs #2679 --- .forgejo/workflows/deploy-tradein.yml | 100 ++++++++++++++++++-------- 1 file changed, 71 insertions(+), 29 deletions(-) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index d2425ca4..894dcb02 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -28,7 +28,8 @@ jobs: frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }} browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }} infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }} - scraper: ${{ steps.set-all.outputs.scraper || steps.filter.outputs.scraper }} + # Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE + # в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend. steps: - uses: actions/checkout@v4 with: @@ -91,7 +92,6 @@ jobs: echo "frontend=true" >> "$GITHUB_OUTPUT" echo "browser=true" >> "$GITHUB_OUTPUT" echo "infra=true" >> "$GITHUB_OUTPUT" - echo "scraper=true" >> "$GITHUB_OUTPUT" # Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges # (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes. @@ -115,20 +115,20 @@ jobs: - 'tradein-mvp/docker-compose.prod.yml' - 'tradein-mvp/deploy/**' - '.forgejo/workflows/deploy-tradein.yml' - scraper: - - 'tradein-mvp/backend/app/services/scrapers/**' - - 'tradein-mvp/backend/app/services/scrape_pipeline.py' - - 'tradein-mvp/backend/app/services/scheduler.py' - - 'tradein-mvp/backend/app/scheduler_main.py' - - 'tradein-mvp/backend/app/tasks/**' - # #2188: scheduler исполняет matching/dedup при каждом scrape-тике — - # без этих путей scraper-контейнер оставался на старом коде - # (2026-07-02: fias-dedup доехал до tradein-backend, но не до - # tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть - # scheduler — его правки тоже обязаны пересоздавать контейнер. - - 'tradein-mvp/backend/app/services/matching/**' - - 'tradein-mvp/backend/app/services/house_dedup_merge.py' - - 'tradein-mvp/packages/scraper-kit/**' + # УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом + # «файлов, которые исполняет планировщик», и перечислял только то, + # что вспомнили. Дважды выстрелило одинаково: + # 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не + # до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**, + # house_dedup_merge.py) — залатали случай, не механизм; + # 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py + # в списке не значились → планировщик час крутил старый код, + # деплой при этом отчитался успехом. + # За июнь-август 48% (193 из 402) backend-мержей не попадали ни в + # один из путей списка, т.е. половина правок доезжала до scraper'а + # только со следующим «удачным» деплоем. Теперь пересоздание + # привязано не к списку файлов, а к факту пересборки образа — + # см. SCRAPER_RECREATE в job deploy. # Quality gate: pytest MUST pass before any image is built/deployed (#666). # Runs the tradein-mvp/backend suite; a red test blocks build + deploy. @@ -295,20 +295,28 @@ jobs: env: IMAGE_TAG: latest GHCR_PAT: ${{ secrets.GHCR_PAT }} - # Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT - # recreate the scraper and SIGKILL a running multi-hour job. Only genuine - # scraper-code paths (the `scraper` paths-filter already covers - # app/services/scrapers/**, scrape_pipeline.py, scheduler.py, - # scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch — - # should trigger a scraper recreate. (infra term intentionally dropped.) - SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }} + # #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ + # gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса, + # одна строка image, разный command). Значит вопрос «пересоздавать ли + # scraper» — это не «трогали ли его файлы», а «мог ли пересобраться + # образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend: + # backend || infra || workflow_dispatch. Ровно тогда в реестре мог + # появиться новый :latest, и оставить scraper на старом — значит + # оставить планировщик на старом коде (инцидент #2679). + # + # Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ + # пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс + # больше не нужен — с #1951 перед recreate'ом идёт graceful drain + # (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам + # `compose up -d` на неизменившемся образе — no-op. + SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }} GITHUB_SHA: ${{ github.sha }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED,GITHUB_SHA + envs: IMAGE_TAG,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA script: | set -euo pipefail cd /opt/gendesign @@ -489,8 +497,9 @@ jobs: # с browser/backend/frontend, отдельного graceful-drain не требует. SERVICES="browser backend frontend tgbot" SCRAPER_STOP_TS="" - if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then - echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)" + if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then + echo "→ backend-образ мог пересобраться — scraper пересоздаётся вместе с backend (#2679);" + echo " ждём слива in-flight scrape_runs (до 5 мин)" drained="" for i in $(seq 1 30); do # NB: не сливать "psql не ответил" с "0 running" — иначе неудачный @@ -530,12 +539,13 @@ jobs: SERVICES="$SERVICES scraper" else - echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)" + echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем" + echo " (сверка образов ниже всё равно проверит, что он не отстал)" fi docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES - if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then + if [ "${SCRAPER_RECREATE:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" # NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до # сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD) @@ -653,7 +663,7 @@ jobs: # снижает шанс поймать контейнер ровно в момент between-restarts # промежуточного "running" внутри crash-loop. # tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES); - # scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому + # scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому # проверяем только то, что реально входит в текущий $SERVICES. for svc in tgbot scraper; do case " $SERVICES " in @@ -683,6 +693,38 @@ jobs: echo "→ tradein-$svc running." done + # Сверка образов backend-семейства (#2679) — последняя проверка перед + # маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ + # gendesign-tradein-backend; backend пересоздаётся на каждом деплое + # (безусловно в $SERVICES) и потому всегда несёт свежий :latest — + # он и есть эталон. Если у scraper или tgbot image ID другой, значит + # контейнер остался на старом коде, а деплой без этой проверки + # отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до + # tradein-backend, ff98603ba3cc; tradein-scraper остался на + # da26154c64a6 часовой давности — а планировщик, единственный + # исполнитель домовой оценки, живёт именно там). + # Падаем, а не warning'уем: расхождение = правка не работает, и + # узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт + # ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же + # базу и пересоберёт всё накопленное (тот же приём, что в health-check). + backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "missing") + image_mismatch="" + for svc in scraper tgbot; do + svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "missing") + if [ "$svc_image" != "$backend_image" ]; then + echo "ERROR: tradein-$svc на образе $svc_image, tradein-backend — на $backend_image" + image_mismatch="yes" + fi + done + if [ -n "$image_mismatch" ]; then + echo "ERROR: образы backend-семейства разошлись — деплой FAILED (#2679)." + echo " Лечение вручную: docker compose -p gendesign-tradein \\" + echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\" + echo " up -d --force-recreate --no-deps scraper tgbot" + exit 1 + fi + echo "→ образы совпадают: backend/scraper/tgbot на $backend_image." + # Cleanup старых образов for repo in ghcr.io/lekss361/gendesign-tradein-backend \ ghcr.io/lekss361/gendesign-tradein-frontend; do From bb6e5c7e41156d14e26f7eecb73397e1d5abb42e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 01:58:34 +0500 Subject: [PATCH 015/130] =?UTF-8?q?fix(tradein/deploy):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D1=82=D1=8C=20=D0=B2=20drain,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=87=D0=B5=D0=B3=D0=BE=20(#2679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SCRAPER_RECREATE` истинно и на infra-правках (compose / workflow / deploy/**), а те почти всегда собирают тот же образ по кэшу: digest не меняется, `up -d` выходит no-op — и за него платили пятиминутным ожиданием слива scrape_runs, прерывая сбор. Теперь после `docker compose pull` (порядок важен: до pull'а под :latest ещё старый образ) сравниваем ID подтянутого образа с тем, на котором бежит tradein-scraper. Совпало — печатаем «пересоздавать нечего» и идём дальше без drain'а; не совпало или контейнера/тега нет — drain как раньше. Заодно закрыт ложный startup-reap: чекпоинт и reap завязаны на тот же признак и больше не выполняются, когда recreate'а не было. Иначе прогон, переживший таймаут drain'а в НЕ пересозданном контейнере, помечался бы 'cancelled', продолжая работать. scraper остаётся в $SERVICES в обоих случаях — при совпавшем образе `up -d` no-op, но правка самого compose (env/лимиты сервиса) так всё же доезжает. Сверка образов: «контейнера нет» и «контейнер отстал» теперь разные сообщения — это разные аварии и чинятся по-разному; отсутствие tradein-backend (эталона) тоже отдельная строка. Refs #2679 --- .forgejo/workflows/deploy-tradein.yml | 75 +++++++++++++++++++++------ 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 894dcb02..102dfde0 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -294,6 +294,9 @@ jobs: uses: appleboy/ssh-action@v1.0.3 env: IMAGE_TAG: latest + # Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не + # уходить в drain, когда пересоздавать нечего (см. ниже, #2679). + IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }} GHCR_PAT: ${{ secrets.GHCR_PAT }} # #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ # gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса, @@ -316,7 +319,7 @@ jobs: username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA + envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA script: | set -euo pipefail cd /opt/gendesign @@ -497,8 +500,42 @@ jobs: # с browser/backend/frontend, отдельного graceful-drain не требует. SERVICES="browser backend frontend tgbot" SCRAPER_STOP_TS="" + scraper_stale="" if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then - echo "→ backend-образ мог пересобраться — scraper пересоздаётся вместе с backend (#2679);" + # Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE + # истинно и на infra-правках (compose / workflow / deploy/**), а те + # почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется, + # `up -d` выходит no-op — и платить за него пятиминутным drain'ом, + # прерывая многочасовой сбор, не за что. Сравниваем, на том ли + # образе бежит scraper, что уже лежит в локальном демоне. + # ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) — + # до pull'а под тегом :latest ещё старый образ, сравнение всегда + # «совпало» и drain пропускался бы как раз тогда, когда он нужен. + # Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на + # ЭТОТ же признак и больше не выполняются, когда recreate'а не было + # (иначе живой прогон с heartbeat старше чекпоинта помечался бы + # 'cancelled', продолжая работать). + pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "") + running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "") + if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then + echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего," + echo " drain пропускаем, in-flight прогоны не трогаем" + else + scraper_stale="yes" + fi + # scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d` + # — no-op, но правка самого compose (env/лимиты сервиса) так всё же + # доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а — + # страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку + # прогона подчистит периодический 6h zombie-reaper. + SERVICES="$SERVICES scraper" + else + echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем" + echo " (сверка образов ниже всё равно проверит, что он не отстал)" + fi + + if [ -n "$scraper_stale" ]; then + echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);" echo " ждём слива in-flight scrape_runs (до 5 мин)" drained="" for i in $(seq 1 30); do @@ -536,16 +573,11 @@ jobs: SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS="" echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}" - - SERVICES="$SERVICES scraper" - else - echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем" - echo " (сверка образов ниже всё равно проверит, что он не отстал)" fi docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES - if [ "${SCRAPER_RECREATE:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then + if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" # NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до # сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD) @@ -707,20 +739,31 @@ jobs: # узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт # ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же # базу и пересоберёт всё накопленное (тот же приём, что в health-check). - backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "missing") + # «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся + # по-разному, поэтому сообщения различаются явно. + backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "") image_mismatch="" + if [ -z "$backend_image" ]; then + echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем." + image_mismatch="yes" + fi for svc in scraper tgbot; do - svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "missing") - if [ "$svc_image" != "$backend_image" ]; then - echo "ERROR: tradein-$svc на образе $svc_image, tradein-backend — на $backend_image" + svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "") + if [ -z "$svc_image" ]; then + echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший" + echo " образ, а неполный стек: сервис не работает вообще." + image_mismatch="yes" + elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then + echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image" image_mismatch="yes" fi done if [ -n "$image_mismatch" ]; then - echo "ERROR: образы backend-семейства разошлись — деплой FAILED (#2679)." - echo " Лечение вручную: docker compose -p gendesign-tradein \\" - echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\" - echo " up -d --force-recreate --no-deps scraper tgbot" + echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)." + echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):" + echo " docker compose -p gendesign-tradein \\" + echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\" + echo " up -d --force-recreate --no-deps backend scraper tgbot" exit 1 fi echo "→ образы совпадают: backend/scraper/tgbot на $backend_image." From 46bbb7988102b1b6a76aa3df1b4a3d44b463169f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 02:29:11 +0500 Subject: [PATCH 016/130] =?UTF-8?q?fix(tradein):=20=D1=81=D0=B8=D0=B3?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8B=20=D0=BE=20=D1=81=D0=B1=D0=BE=D1=8F?= =?UTF-8?q?=D1=85=20=D0=BD=D0=B0=D0=BA=D0=BE=D0=BD=D0=B5=D1=86=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D1=8F=D1=82=D1=81=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=D0=BC=D0=B8,=20=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D1=83=D1=85=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D1=83=D0=BA=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B0=D0=B5=D1=82=20=D0=B7=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=B5=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В скрапер-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR), поэтому любой сигнал уровня WARNING событием не становится — сколько бы раз он ни срабатывал. Прод это подтвердил: монитор устаревания СберИндекса отработал 24 раза, 9 из них со staleness-вердиктом, событий ноль; куки Домклика протухли 2026-08-03 и об этом никто не узнал. Разбирали не «поменять warning на error», а по каждому сигналу: сбой, из-за которого данные перестают обновляться — событие; рутина и ожидаемые состояния — лог. Плюс предупреждение ЗАРАНЕЕ там, где чинить нужно руками (куки Домклика — по образцу #2658 для Циана, переиспользован тот же подход session_expires_at + COOKIE_EXPIRY_WARN_DAYS). У поллера Росреестра выход нового квартала оставлен уровнем info, но получил явный capture_message(level="info"): новость хорошая, но требует ручного импорта оператором, а INFO-строка живёт только до ближайшего редеплоя. logger.error для неё был бы враньём в error-rate и стрик-алертах. Оговорка: у GlitchTip-проекта сейчас нет ни правил, ни получателей (#2673) — события станут видны в интерфейсе, но никому не отправятся. Refs #2674 --- .../backend/app/services/domclick_session.py | 39 ++ .../backend/app/services/rosreestr_poll.py | 48 ++- .../backend/app/services/sber_index.py | 10 +- .../app/tasks/deals_freshness_monitor.py | 4 +- .../app/tasks/domclick_detail_backfill.py | 76 +++- .../app/tasks/sber_freshness_monitor.py | 23 +- .../tasks/test_domclick_detail_backfill.py | 52 +-- .../tests/test_alerts_become_events.py | 349 ++++++++++++++++++ tradein-mvp/backend/tests/test_sber_index.py | 15 +- 9 files changed, 565 insertions(+), 51 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_alerts_become_events.py diff --git a/tradein-mvp/backend/app/services/domclick_session.py b/tradein-mvp/backend/app/services/domclick_session.py index 177ee772..85fdef97 100644 --- a/tradein-mvp/backend/app/services/domclick_session.py +++ b/tradein-mvp/backend/app/services/domclick_session.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import logging +from datetime import datetime from sqlalchemy import text from sqlalchemy.orm import Session @@ -25,6 +26,13 @@ from app.core.config import settings logger = logging.getLogger(__name__) +# За сколько дней до протухания кук предупреждать (#2674, по образцу #2658 для Циана). +# Обновление кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас: +# сигнал по факту протухания приходит, когда обогащение уже встало. Прод 2026-08-03: +# куки протухли, единственным следом был WARNING в docker-логе, который к тому же +# теряется при редеплое. save_session ставит ttl 30 дней, так что окно широкое. +COOKIE_EXPIRY_WARN_DAYS = 5 + # Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением. # Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной # test-аккаунт сессии (Sber ID login), 2026-07-04. @@ -148,6 +156,37 @@ def load_session(db: Session) -> dict[str, str] | None: return cookies +def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None: + """Когда протухают самые свежезагруженные куки (#2674, зеркалит cian_session #2658). + + `load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на + протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от + «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. + + valid_only=False (диагностика после None от load_session) — свежайшая запись любая: + валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же + запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок + ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке. + """ + row = db.execute( + text( + """ + SELECT expires_at_estimate FROM domclick_session_cookies + WHERE NOT CAST(:valid_only AS boolean) + OR (expires_at_estimate > NOW() + AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)) + ORDER BY uploaded_at DESC + LIMIT 1 + """ + ), + {"valid_only": valid_only}, + ).first() + if row is None: + return None + expires_at: datetime | None = row[0] + return expires_at + + def mark_session_invalid(db: Session, account_cas_id: int) -> None: """Flag session как expired/invalid (например после блока во время scrape).""" db.execute( diff --git a/tradein-mvp/backend/app/services/rosreestr_poll.py b/tradein-mvp/backend/app/services/rosreestr_poll.py index 1ef1d8df..3fa293b0 100644 --- a/tradein-mvp/backend/app/services/rosreestr_poll.py +++ b/tradein-mvp/backend/app/services/rosreestr_poll.py @@ -50,9 +50,18 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн, что DEFAULT_UA в zhkh_flats_loader.py). -При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем -available=False. Отсутствие папки/файла квартала → available=False (штатный -случай до публикации квартала, до начала следующего месяца после конца квартала). +УРОВНИ СИГНАЛОВ (#2674 — в контейнере скрапера событием GlitchTip становится только +запись ERROR, см. scheduler_main.py LoggingIntegration(event_level=ERROR)): + - Портал ответил не-200 на листинг каталога/папки → ERROR. Каталог — единственная + опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча + врал целыми кварталами. Такое обязано быть событием. + - Таймаут / сетевая ошибка → WARNING, как раньше. Это транспортный блип раз в месяц + (такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный + deals_freshness_monitor ERROR-ом по max(deal_date). + - Папки/файла квартала нет → INFO. Штатное состояние до публикации: квартал выходит + 4 раза в год, поллер ходит 12 — большинство прогонов ЗАКОННО пустые. + - Квартал вышел → INFO + ЯВНОЕ событие capture_message(level="info"), см. + poll_rosreestr_new_quarter. """ from __future__ import annotations @@ -63,6 +72,7 @@ from typing import Any from urllib.parse import quote, unquote, urljoin import httpx +import sentry_sdk from sqlalchemy import text from sqlalchemy.orm import Session @@ -243,7 +253,8 @@ async def check_new_quarter_available( try: index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True) if index_resp.status_code != 200: - logger.warning( + # ERROR (#2674): без каталога поллер слеп — см. "УРОВНИ СИГНАЛОВ". + logger.error( "rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable", index_resp.status_code, _DATA_SETS_BASE_URL, @@ -265,7 +276,9 @@ async def check_new_quarter_available( folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href) folder_resp = await client.get(folder_url, follow_redirects=True) if folder_resp.status_code != 200: - logger.warning( + # ERROR (#2674): папка квартала НАЙДЕНА в каталоге, но не открывается — + # это уже не «ещё не опубликовали», а поломка портала. + logger.error( "rosreestr_poll: unexpected HTTP %d listing folder %s — " "treating Q%d %d as unavailable", folder_resp.status_code, @@ -338,12 +351,14 @@ async def check_new_quarter_available( exc, ) return False - except Exception as exc: - logger.warning( - "rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable", + except Exception: + # ERROR + traceback (#2674): сюда попадает НАШ баг (сменилась разметка, упал + # парсер href'ов), а не сбой сети. Под WARNING он молча превращался в + # «квартала нет» — ровно тот сценарий, из-за которого поллер врал кварталами. + logger.exception( + "rosreestr_poll: unexpected error checking Q%d %d — treating as unavailable", quarter, year, - exc, ) return False @@ -409,6 +424,21 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]: rosreestr_dataset_url(next_year, next_quarter), _DATA_SETS_BASE_URL, ) + # #2674: это ХОРОШАЯ новость, но она требует ручного шага оператора (импорт + # много-гигабайтного ZIP), а INFO-строка живёт только в docker-логах и + # теряется на редеплое. Отсюда явный capture_message вместо logger.error: + # событие в GlitchTip будет, а error-rate и стрик-алерты не соврут «сбой». + # Шума не создаёт: такт поллера — раз в 28 дней, квартал выходит 4 раза в + # год, а повтор до самого импорта — это и есть нужное напоминание (#2670). + try: + sentry_sdk.capture_message( + f"Rosreestr: доступен новый квартал Q{next_quarter} {next_year} — " + "нужен ручной импорт (02_load_all_quarters.sh + import-rosreestr.sh)", + level="info", + ) + except Exception: + # Алертинг best-effort: падение отправки события не должно валить поллер. + logger.warning("rosreestr_poll: capture_message failed", exc_info=True) return { "available": available, diff --git a/tradein-mvp/backend/app/services/sber_index.py b/tradein-mvp/backend/app/services/sber_index.py index 36b7bc95..7a4d646c 100644 --- a/tradein-mvp/backend/app/services/sber_index.py +++ b/tradein-mvp/backend/app/services/sber_index.py @@ -464,8 +464,16 @@ async def pull_sber_indices( # path or its filter dims are stale (sber renames slugs / changes # dimension codes). Surface it loudly with the slug + filter so the # next breakage is diagnosable instead of a silent error-counter bump. + # + # #2674: "loudly" было сказано, но написано WARNING — тише, чем + # соседние 5xx/сетевые ветки, и НЕ событие в скрапере + # (LoggingIntegration event_level=ERROR). При этом 404 — самая + # ПЕРМАНЕНТНАЯ из трёх: 5xx и сетевой сбой сами пройдут, а + # переименованный slug будет 404-ить каждый месяц, пока человек не + # перезахватит dataset-path. Ровно тот сбой, из-за которого бенчмарк + # перестаёт обновляться. if exc.response.status_code == 404: - logger.warning( + logger.error( "sber_index: 404 for dashboard=%s ref_area=%s filter=%s — " "dataset-path invalid? slug renamed or filter dims stale " "(re-capture /dataset/v1/ via dashboard route-interception)", diff --git a/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py b/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py index b585551c..db7440c1 100644 --- a/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py @@ -142,7 +142,9 @@ def check_deals_freshness( row = db.execute(_LATEST_DEAL_DATE_SQL).first() latest: date | None = row.latest if row is not None else None if latest is None: - logger.warning( + # ERROR (#2674): монитор не может выполнить работу — сбой, а не наблюдение. + # Соседняя ветка (overdue) писала ERROR с самого начала; эта расходилась. + logger.error( "deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя" ) runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters) diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 319e9f95..ce158121 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -52,10 +52,16 @@ Exception triad differs from Avito: Cookie injection is mandatory wiring, not optional: cookies are loaded ONCE per run. If None (no valid session uploaded / expired) -- the run still proceeds (cookie- injection is a QRATOR-defeat mechanism, not a hard requirement; organic SERP-origin -navigation from PR #2430 still applies) but a warning is logged once at run start so +navigation from PR #2430 still applies) but an ERROR is logged once at run start so operators notice the test-account session needs refreshing via `POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation, see app/services/domclick_session.py module docstring). + +#2674: раньше это был WARNING, который в скрапер-контейнере событием не становится +(LoggingIntegration event_level=ERROR) — куки протухли 2026-08-03 и об этом никто не +узнал. Теперь два сигнала вместо одного: ERROR по факту (_alert_domclick_cookies) и +ERROR ЗАРАНЕЕ, пока куки ещё живы (_warn_before_domclick_cookies_expire) — по образцу +#2658 для Циана, ручное обновление кук требует запаса времени. """ from __future__ import annotations @@ -65,6 +71,7 @@ import logging import random import time from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError @@ -85,6 +92,60 @@ __all__ = [ ] +def _alert_domclick_cookies(db: Session, run_id: int) -> None: + """Громкий сигнал «обогащение идёт без кук» — logger.error, не warning (#2674). + + В контейнере скрапера GlitchTip поднят с LoggingIntegration(event_level=ERROR) + (scheduler_main.py), поэтому прежний WARNING событием не становился: куки протухли + на проде 2026-08-03, и единственным следом была строка в docker-логе, которая + теряется при редеплое. Прогон при этом НЕ прерываем — cookie-инъекция это + механизм обхода QRATOR, а не жёсткое требование (см. докстринг модуля), — но + состояние требует ручного действия человека, значит должно быть событием. + + Причину различаем так же, как #2658 у Циана: «кук нет вовсе» и «протухли N дней + назад» лечатся одинаково, но диагностируются по-разному. + """ + expires_at = domclick_session_svc.session_expires_at(db) + now = datetime.now(tz=UTC) + if expires_at is None: + detail = "кук DomClick нет в БД" + elif expires_at <= now: + detail = ( + f"куки DomClick протухли {expires_at:%Y-%m-%d} " + f"({(now - expires_at).days} дн. назад)" + ) + else: + detail = "куки DomClick помечены невалидными (last_invalid_at)" + logger.error( + "domclick_detail_backfill: run_id=%d — %s; обогащение идёт БЕЗ cookie-инъекции " + "(QRATOR-обход деградировал до organic SERP-origin навигации, PR #2430). " + "Перезалейте сессию test-аккаунта: POST /scrape/domclick/upload-cookies", + run_id, + detail, + ) + + +def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None: + """Предупредить ЗАРАНЕЕ, пока куки ещё рабочие (#2674, образец — #2658 для Циана). + + Сигнал по факту протухания приходит, когда обогащение уже встало; обновление кук + ручное, человеку нужен запас. valid_only=True — срок ИМЕННО той записи, которую + взял load_session (при нескольких аккаунтах свежайшая-любая может быть чужой). + """ + expires_at = domclick_session_svc.session_expires_at(db, valid_only=True) + if expires_at is None: + return + left = expires_at - datetime.now(tz=UTC) + if left <= timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS): + logger.error( + "domclick_detail_backfill: run_id=%d — куки DomClick протухнут %s " + "(осталось %.1f дн.); обновите заранее, иначе обогащение деградирует молча", + run_id, + expires_at.date().isoformat(), + left.total_seconds() / 86400, + ) + + @dataclass class DomClickDetailBackfillResult: """Counters for one backfill run.""" @@ -135,16 +196,13 @@ async def run_domclick_detail_backfill( try: # Cookie injection (#2000 PR #2433) -- loaded ONCE per run, threaded into every - # fetch_detail() call below. None is a valid (degraded) state, not an error. + # fetch_detail() call below. Прогон продолжается и без кук (см. докстринг), но + # это состояние требует ЧЕЛОВЕКА: обновление сессии — ручная операция. cookies = domclick_session_svc.load_session(db) if cookies is None: - logger.warning( - "domclick_detail_backfill: run_id=%d -- no valid DomClick session cookies " - "in DB; proceeding WITHOUT cookie-injection (QRATOR-defeat degraded to " - "organic SERP-origin navigation only, PR #2430). Refresh test-account " - "session via POST /scrape/domclick/upload-cookies.", - run_id, - ) + _alert_domclick_cookies(db, run_id) + else: + _warn_before_domclick_cookies_expire(db, run_id) runs_mod.update_heartbeat(db, run_id, current_counters) diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 00e49fdc..22193491 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -9,9 +9,17 @@ видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и -поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага +поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. +#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с +LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING +событием НЕ становится: на проде монитор отработал 24 раза, из них 9 со +staleness-вердиктом — и ни одного события. Бенчмарк цен участвует в сверке наших +медиан, его застой — сбой, а не наблюдение. Сосед по конструкции +(deals_freshness_monitor) писал ERROR с самого начала — расходилась только эта +джоба. + Порог алерта (документирование выбора): Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д). Монитор: age > sber_index_max_age_days + lag_allowance. @@ -27,8 +35,8 @@ kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now тестируется без БД). -Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING -достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего +Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи +достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего оценивать). """ @@ -136,7 +144,10 @@ def check_sber_freshness( row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first() latest: date | None = row.latest if row is not None else None if latest is None: - logger.warning( + # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, + # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), + # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. + logger.error( "sber freshness: sber_price_index пуст/недоступен для region=%s " "(вторичка) — оценить свежесть нельзя", SBER_MONITOR_CITY, @@ -156,7 +167,9 @@ def check_sber_freshness( } if verdict.stale: - logger.warning( + # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) — + # 9 срабатываний на проде дали ноль событий. См. докстринг модуля. + logger.error( "sber freshness: max(period_month)=%s устарел на %d дней " "(> порога %d = sber_index_max_age_days %d + lag %d); " "СберИндекс time-adjustment ДКП-сделок мог отстать — " diff --git a/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py index 2cacce98..dd2b28f6 100644 --- a/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py @@ -9,8 +9,10 @@ _mock_db(snapshot) helper, runs = MagicMock() assertions. DomClick-специф from __future__ import annotations +import logging import os import sys +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") @@ -60,6 +62,20 @@ def _mock_db(snapshot: list[dict]) -> MagicMock: return db +def _mock_session_svc(cookies: dict[str, str] | None) -> MagicMock: + """Fake domclick_session модуль: load_session + срок годности кук (#2674). + + session_expires_at по умолчанию далеко в будущем — иначе каждый тест ловил бы + предупреждение «куки скоро протухнут». Отдельно оно проверяется в + tests/test_alerts_become_events.py. + """ + svc = MagicMock() + svc.load_session.return_value = cookies + svc.COOKIE_EXPIRY_WARN_DAYS = 5 + svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30) + return svc + + def _mock_browser_fetcher_cls() -> MagicMock: """MagicMock class whose instance is a working async context manager.""" instance = AsyncMock() @@ -88,8 +104,7 @@ async def test_backfill_empty_snapshot_marks_done() -> None: db = _mock_db([]) runs = MagicMock() fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -123,8 +138,7 @@ async def test_backfill_processes_snapshot_with_cookies_threaded() -> None: mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") fake_cookies = {"CAS_ID": "999", "qrator_jsid2": "abc"} - mock_svc = MagicMock() - mock_svc.load_session.return_value = fake_cookies + mock_svc = _mock_session_svc(fake_cookies) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -156,9 +170,10 @@ async def test_backfill_processes_snapshot_with_cookies_threaded() -> None: @pytest.mark.asyncio -async def test_backfill_cookies_none_still_proceeds_with_warning(caplog) -> None: +async def test_backfill_cookies_none_still_proceeds_with_error_alert(caplog) -> None: """No valid session (load_session()->None) -> run still proceeds (fetch_detail - called with cookies=None), but a warning is logged so operators refresh the session. + called with cookies=None), но сигнал теперь ERROR, а не WARNING (#2674): в + скрапер-контейнере событием GlitchTip становится только ERROR. """ snapshot = _make_snapshot(1) db = _mock_db(snapshot) @@ -167,8 +182,8 @@ async def test_backfill_cookies_none_still_proceeds_with_warning(caplog) -> None mock_fetch = AsyncMock(return_value=mock_enrichment) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = None + mock_svc = _mock_session_svc(None) + mock_svc.session_expires_at.return_value = None # кук никогда не загружали mock_bf_cls = _mock_browser_fetcher_cls() with ( caplog.at_level("WARNING"), @@ -187,7 +202,8 @@ async def test_backfill_cookies_none_still_proceeds_with_warning(caplog) -> None assert result.enriched == 1 _, kwargs = mock_fetch.call_args assert kwargs.get("cookies") is None - assert "no valid DomClick session cookies" in caplog.text + assert "кук DomClick нет в БД" in caplog.text + assert [r for r in caplog.records if r.levelno >= logging.ERROR] runs.mark_done.assert_called_once() runs.mark_failed.assert_not_called() @@ -205,8 +221,7 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None: blocked_exc = DomClickBlockedError("QRATOR challenge page detected") mock_fetch = AsyncMock(side_effect=blocked_exc) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -243,8 +258,7 @@ async def test_backfill_parse_error_counts_failed_no_abort() -> None: ) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -281,8 +295,7 @@ async def test_backfill_sigterm_drain_breaks_and_marks_done_partial() -> None: mock_fetch = AsyncMock(return_value=mock_enrichment) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -316,8 +329,7 @@ async def test_backfill_budget_guard_stops_loop() -> None: runs = MagicMock() mono_values = iter([0.0, 999.0, 999.0]) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -340,8 +352,7 @@ async def test_backfill_top_level_exception_marks_failed() -> None: db.execute.side_effect = RuntimeError("DB connection lost") runs = MagicMock() fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -367,8 +378,7 @@ async def test_backfill_generic_exception_continues_and_rolls_back() -> None: mock_enrichment = MagicMock() mock_fetch = AsyncMock(side_effect=[RuntimeError("unexpected"), mock_enrichment]) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py new file mode 100644 index 00000000..a58d5910 --- /dev/null +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -0,0 +1,349 @@ +"""Сигналы о сбоях действительно становятся событиями GlitchTip (#2674). + +Почему обычной проверки уровня записи мало. В контейнере скрапера GlitchTip поднят +как `LoggingIntegration(level=INFO, event_level=ERROR)` (app/scheduler_main.py) — +значит WARNING остаётся строкой в docker-логе (которая теряется на редеплое) и +событием НЕ становится. Прод-цена этого: монитор устаревания СберИндекса отработал +24 раза, 9 из них со staleness-вердиктом — событий ноль; куки Домклика протухли +2026-08-03 — событий ноль. + +Поэтому здесь тесты проверяют ФАКТ СОБЫТИЯ, а не levelno: `glitchtip_events()` +поднимает настоящий sentry-клиент с той же интеграцией и тем же event_level, что в +проде, но с транспортом-списком. Если правку откатить (ERROR → WARNING), список +останется пустым и тест покраснеет. + +Оговорка, которую тесты проверить не могут: у GlitchTip-проекта сейчас нет ни правил, +ни получателей (#2673) — события будут видны в интерфейсе, но никому не отправятся. + +Без сети, без БД. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, date, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import domclick_session as domclick_session_svc +from app.services import rosreestr_poll, sber_index +from app.tasks import deals_freshness_monitor as deals_mon +from app.tasks import domclick_detail_backfill as dc_backfill +from app.tasks import sber_freshness_monitor as sber_mon + +# ── харнесс: настоящий клиент GlitchTip с прод-настройками, транспорт — список ── + + +@contextmanager +def glitchtip_events() -> Iterator[list[dict[str, Any]]]: + """Собрать события так, как их увидел бы GlitchTip из контейнера скрапера. + + Интеграция и event_level — копия app/scheduler_main.py. Клиент ставится только + на время блока (isolation_scope), глобальное состояние не трогаем. + """ + events: list[dict[str, Any]] = [] + + def _collect(event: dict[str, Any], _hint: dict[str, Any]) -> None: + """before_send: событие уже собрано — записываем и НЕ отправляем (None).""" + events.append(event) + return None + + client = sentry_sdk.Client( + dsn="https://public@localhost/1", + before_send=_collect, + default_integrations=False, + integrations=[LoggingIntegration(level=logging.INFO, event_level=logging.ERROR)], + ) + with sentry_sdk.isolation_scope() as scope: + scope.set_client(client) + yield events + + +def event_texts(events: list[dict[str, Any]]) -> list[str]: + """Тексты событий — и логовых (logentry), и явных capture_message (message).""" + texts: list[str] = [] + for event in events: + entry = event.get("logentry") + if isinstance(entry, dict): + texts.append(str(entry.get("formatted") or entry.get("message") or "")) + elif isinstance(event.get("message"), str): + texts.append(event["message"]) + return texts + + +def test_harness_itself_drops_warnings() -> None: + """Мета-проверка харнесса: WARNING не событие, ERROR — событие. + + Без этого зелёные тесты ниже ничего не доказывали бы (пустой список мог бы быть + следствием сломанного харнесса, а не сломанного алерта). + """ + log = logging.getLogger("test_alerts_become_events.meta") + with glitchtip_events() as events: + log.warning("тихо") + log.error("громко") + assert event_texts(events) == ["громко"] + + +# ── 1. СберИндекс: монитор устаревания ──────────────────────────────────────── + + +class _FakeMonitorDB: + """Session-мок мониторов свежести: один SELECT max(...).""" + + def __init__(self, latest: date | None) -> None: + self._latest = latest + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + result = MagicMock() + result.first.return_value = MagicMock(latest=self._latest) + return result + + def rollback(self) -> None: + pass + + +def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None: + monkeypatch.setattr(module.runs_mod, "update_heartbeat", lambda *a, **k: None) + monkeypatch.setattr(module.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None) + + +def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие.""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(date(2026, 6, 1)) + with glitchtip_events() as events: + out = sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=1, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert out["alert"] == 1 + assert any( + "sber freshness" in t for t in event_texts(events) + ), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает" + + +def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Свежие данные — ни одного события (иначе алерт-усталость).""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(date(2026, 6, 1)) + with glitchtip_events() as events: + out = sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=2, + params={}, + now=datetime(2026, 6, 20, tzinfo=UTC), + ) + assert out["alert"] == 0 + assert event_texts(events) == [] + + +def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Бенчмарк пуст — монитор не может работать вовсе; mark_failed виден только стрику.""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(None) + with glitchtip_events() as events: + sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=3, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert any("sber_price_index пуст" in t for t in event_texts(events)) + + +def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Тот же класс у соседнего монитора сделок — найдено «шире» по #2674.""" + _patch_runs(monkeypatch, deals_mon) + db = _FakeMonitorDB(None) + with glitchtip_events() as events: + deals_mon.check_deals_freshness( + db, # type: ignore[arg-type] + run_id=4, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert any("deals пуста" in t for t in event_texts(events)) + + +# ── 2. СберИндекс: 404 датасета (почему бенчмарк перестаёт обновляться) ──────── + + +async def test_sber_index_404_becomes_event() -> None: + """404 = переименованный slug: сбой ПЕРМАНЕНТНЫЙ, а был тише соседних 5xx-веток.""" + request = httpx.Request("GET", "https://sberindex.ru/api/sowa") + + def _raise_404(*args: Any, **kwargs: Any) -> Any: + raise httpx.HTTPStatusError( + "404", request=request, response=httpx.Response(404, request=request) + ) + + with ( + patch.object(sber_index, "fetch_sber_index", _raise_404), + glitchtip_events() as events, + ): + counters = await sber_index.pull_sber_indices( + MagicMock(), + cities={"66": "Свердловская область"}, + dashboards=[sber_index.SBER_DASHBOARDS[0]], + ) + + assert counters["errors"] == 1 + assert any("404 for dashboard" in t for t in event_texts(events)) + + +# ── 3. Куки Домклика: по факту и ЗАРАНЕЕ (образец — #2658 для Циана) ────────── + + +class _FakeDomclickDB: + """Session-мок: строка кук для session_expires_at + пустой снапшот листингов.""" + + def __init__(self, expires_at: datetime | None) -> None: + self._expires_at = expires_at + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + result = MagicMock() + if "domclick_session_cookies" in str(stmt): + result.first.return_value = None if self._expires_at is None else (self._expires_at,) + else: + # snapshot листингов — пусто, прогон завершится до BrowserFetcher + result.mappings.return_value.all.return_value = [] + return result + + +async def _run_domclick( + monkeypatch: pytest.MonkeyPatch, + *, + cookies: dict[str, str] | None, + expires_at: datetime | None, +) -> list[str]: + _patch_runs(monkeypatch, dc_backfill) + monkeypatch.setattr(domclick_session_svc, "load_session", lambda _db: cookies) + db = _FakeDomclickDB(expires_at) + with glitchtip_events() as events: + await dc_backfill.run_domclick_detail_backfill( + db, # type: ignore[arg-type] + run_id=10, + params={}, + ) + return event_texts(events) + + +async def test_domclick_expired_cookies_become_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод 2026-08-03: протухли, единственным следом был WARNING в docker-логе.""" + expired = datetime.now(tz=UTC) - timedelta(days=3) + texts = await _run_domclick(monkeypatch, cookies=None, expires_at=expired) + assert any("протухли" in t for t in texts), "протухшие куки не стали событием" + # Дата в тексте — чтобы оператор сразу видел, чинить сейчас или это давняя дыра. + assert any(expired.strftime("%Y-%m-%d") in t for t in texts) + + +async def test_domclick_missing_cookies_reason_differs(monkeypatch: pytest.MonkeyPatch) -> None: + """«Кук нет вовсе» и «протухли» лечатся одинаково, но диагностируются по-разному.""" + texts = await _run_domclick(monkeypatch, cookies=None, expires_at=None) + assert any("кук DomClick нет в БД" in t for t in texts) + + +async def test_domclick_warns_before_expiry(monkeypatch: pytest.MonkeyPatch) -> None: + """Предупреждение ЗАРАНЕЕ: куки ещё рабочие, но жить им меньше порога. + + Сигнал по факту протухания приходит, когда обогащение уже встало, а обновление + кук — ручная операция. По образцу #2658 (Циан). + """ + soon = datetime.now(tz=UTC) + timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS - 1) + texts = await _run_domclick(monkeypatch, cookies={"CAS_ID": "x"}, expires_at=soon) + assert any("протухнут" in t for t in texts), "не предупредили заранее" + + +async def test_domclick_fresh_cookies_are_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Свежие куки — ни одного события.""" + far = datetime.now(tz=UTC) + timedelta(days=25) + texts = await _run_domclick(monkeypatch, cookies={"CAS_ID": "x"}, expires_at=far) + assert texts == [] + + +def test_domclick_expiry_query_asks_for_the_used_row() -> None: + """valid_only=True — срок ИМЕННО той записи, которую взял бы load_session. + + При нескольких аккаунтах свежайшая-любая может быть чужой протухшей строкой. + """ + db = MagicMock() + db.execute.return_value.first.return_value = None + domclick_session_svc.session_expires_at(db, valid_only=True) + assert db.execute.call_args.args[1] == {"valid_only": True} + + +# ── 4. Поллер Росреестра: сбой каталога vs выход квартала ───────────────────── + + +def _client_returning(status_code: int) -> MagicMock: + client = MagicMock() + client.get = AsyncMock(return_value=httpx.Response(status_code, text="")) + return client + + +async def test_rosreestr_broken_index_becomes_event() -> None: + """Каталог не отдаёт 200 — поллер слеп. Портал уже один раз переезжал.""" + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available( + _client_returning(503), 2026, 3 + ) + assert available is False + assert any("unexpected HTTP 503" in t for t in event_texts(events)) + + +async def test_rosreestr_quarter_not_published_is_silent() -> None: + """Каталог жив, папки квартала ещё нет — самый частый прогон, событий быть не должно.""" + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available( + _client_returning(200), 2026, 3 + ) + assert available is False + assert event_texts(events) == [] + + +async def test_rosreestr_timeout_stays_out_of_events() -> None: + """Осознанно НЕ событие: транспортный блип раз в 28 дней сам пройдёт. + + «Квартал так и не приехал» ловит deals_freshness_monitor по max(deal_date). + """ + client = MagicMock() + client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + with glitchtip_events() as events: + await rosreestr_poll.check_new_quarter_available(client, 2026, 3) + assert event_texts(events) == [] + + +async def test_rosreestr_new_quarter_becomes_event() -> None: + """Хорошая новость — тоже событие: она требует ручного импорта оператором. + + Уровень info, а не error: событие в GlitchTip есть, а error-rate и стрик-алерты + не начинают врать про «сбой». INFO-строка в логе живёт до ближайшего редеплоя. + """ + with ( + patch.object(rosreestr_poll, "latest_loaded_quarter", MagicMock(return_value=(2026, 2))), + patch.object(rosreestr_poll, "check_new_quarter_available", AsyncMock(return_value=True)), + glitchtip_events() as events, + ): + out = await rosreestr_poll.poll_rosreestr_new_quarter(MagicMock()) + + assert out == { + "available": True, + "year": 2026, + "quarter": 3, + "latest_loaded_year": 2026, + "latest_loaded_quarter": 2, + } + assert any("доступен новый квартал Q3 2026" in t for t in event_texts(events)) diff --git a/tradein-mvp/backend/tests/test_sber_index.py b/tradein-mvp/backend/tests/test_sber_index.py index ed31e11c..61b7f1c7 100644 --- a/tradein-mvp/backend/tests/test_sber_index.py +++ b/tradein-mvp/backend/tests/test_sber_index.py @@ -469,10 +469,15 @@ async def test_pull_sber_indices_error_per_series_continues() -> None: @pytest.mark.asyncio -async def test_pull_sber_indices_404_logs_warning_with_path_hint( +async def test_pull_sber_indices_404_logs_error_with_path_hint( caplog: pytest.LogCaptureFixture, ) -> None: - """#902: a 404 logs a WARNING naming the slug + 'dataset-path invalid?' hint.""" + """#902: a 404 logs the slug + 'dataset-path invalid?' hint. + + #2674: уровень поднят WARNING → ERROR. 404 = переименованный slug, самая + ПЕРМАНЕНТНАЯ из трёх веток отказа (5xx и сетевой сбой проходят сами), а до этого + она была тише соседних и событием GlitchTip не становилась. + """ import logging import httpx @@ -498,11 +503,11 @@ async def test_pull_sber_indices_404_logs_warning_with_path_hint( ) assert result["errors"] == 1 - warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + errors = [r for r in caplog.records if r.levelno >= logging.ERROR] assert any( "residential_real_estate_prices" in r.message and "dataset-path invalid" in r.message - for r in warnings - ), f"Expected a 404 dataset-path warning, got: {[r.message for r in warnings]}" + for r in errors + ), f"Expected a 404 dataset-path ERROR, got: {[r.message for r in errors]}" @pytest.mark.asyncio From 43aaf91b97fd5b076ad5b753e93724e49ec9cb07 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 02:29:58 +0500 Subject: [PATCH 017/130] =?UTF-8?q?fix(tradein):=20=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B8=20=D0=BD=D0=B0=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5=D1=86=20=D0=BF=D0=B8=D1=88=D1=83=D1=82=20=D1=82=D0=BE,?= =?UTF-8?q?=20=D1=87=D1=82=D0=BE=20=D0=BE=D0=B1=D0=B5=D1=89=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20=E2=80=94=20=D1=84?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=BA,=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=C2=AB?= =?UTF-8?q?=D1=81=D0=BD=D1=8F=D1=82=D0=BE=C2=BB,=20=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D1=8F=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три находки одного класса из эпика: колонка есть, писатель есть, тест на писателя зелёный — а данные не появляются. Тестами это не ловится по построению, только сверкой с продом. 1. house_suggestions: парсер выбрасывал imageLink, а INSERT не перечислял image_link + area_m2/rooms/floor/total_floors. 25 055 строк с NULL во всех пяти колонках, ~74 дня с миграции 064. Метрики парсятся из title тем же _parse_title, что и у placementHistory. 2. listings_snapshots.status: 'active' у всех 394 299 строк при 55 448 реально неактивных объявлений. Оба места вызова с литералом 'active' честны — там объявление действительно видели; не писал никто ветку «снято». Теперь оба места деактивации пишут снимок 'closed' в ТОЙ ЖЕ транзакции: TTL-задача (data-modifying CTE, все 4 источника через один deactivate_stale_listings) и 404 из avito_detail_backfill. Дата снятия перестаёт быть догадкой. 3. listing_source_events: схема знает 5 типов, писался 1 (price_change, 8288 строк). Дописаны ветки delisted/relisted/edited/first_seen в тот же set-based statement — данные для них уже лежат в снимке. JOIN → LEFT JOIN LATERAL, иначе first_seen недостижим по построению; план #2607 (per-row index point-lookup по idx_lss_source_date) сохранён, проверено EXPLAIN на проде. Счётчики прогона теперь по типам, все пять всегда присутствуют — ровно они показали бы четыре нуля из пяти. Миграция не нужна: все колонки и CHECK уже существуют. Тесты: tests/test_2674_writers_honor_schema.py. Гейты сверяют писателя со СХЕМОЙ (колонки INSERT против CREATE TABLE 064, типы событий против CHECK 079), поэтому ловят и следующую забытую колонку. Фальсификация патч-методом: без фикса 1 — 6 красных, без фикса 2 — 6, без фикса 3 — 4. --- .../app/services/house_imv_backfill.py | 20 +- .../app/tasks/avito_detail_backfill.py | 24 +- .../app/tasks/deactivate_stale_avito.py | 73 +++- .../app/tasks/listing_source_snapshot.py | 161 ++++++-- .../tests/test_2674_writers_honor_schema.py | 362 ++++++++++++++++++ .../tests/test_listing_source_snapshot.py | 42 +- .../src/scraper_kit/providers/avito/imv.py | 21 +- .../src/scraper_kit/snapshot_writer.py | 11 +- 8 files changed, 638 insertions(+), 76 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2674_writers_honor_schema.py diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index 80b90a02..935f3918 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -345,26 +345,37 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat ) # 3. Suggestions + # #2674: до этого фикса в INSERT не входили image_link + area_m2/rooms/floor/ + # total_floors — колонки есть с миграции 064, но писатель их не заполнял + # (25 055 строк на проде с NULL во всех пяти). Ссылка на фото приходит в + # suggestions.items[].imageLink, метрики квартиры парсятся из title. for sug in result.suggestions: db.execute( text(""" INSERT INTO house_suggestions ( house_id, ext_item_id, title, address, price_rub, + area_m2, rooms, floor, total_floors, exposure_days, publish_date, - item_link, metro_name, metro_distance, + item_link, image_link, metro_name, metro_distance, has_good_price_badge, raw_payload, fetched_at ) VALUES ( :hid, :ext, :title, :addr, :price, + CAST(:area AS numeric), :rooms, :floor, :total_floors, :exp, :pdate, - :link, :mname, :mdist, + :link, :img, :mname, :mdist, :gpb, CAST(:raw AS jsonb), NOW() ) ON CONFLICT (house_id, ext_item_id) DO UPDATE SET title = EXCLUDED.title, price_rub = EXCLUDED.price_rub, + area_m2 = EXCLUDED.area_m2, + rooms = EXCLUDED.rooms, + floor = EXCLUDED.floor, + total_floors = EXCLUDED.total_floors, exposure_days = EXCLUDED.exposure_days, publish_date = EXCLUDED.publish_date, item_link = EXCLUDED.item_link, + image_link = EXCLUDED.image_link, metro_name = EXCLUDED.metro_name, metro_distance = EXCLUDED.metro_distance, has_good_price_badge = EXCLUDED.has_good_price_badge, @@ -377,9 +388,14 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat "title": sug.title, "addr": sug.address, "price": sug.price_rub, + "area": sug.area_m2, + "rooms": sug.rooms, + "floor": sug.floor, + "total_floors": sug.total_floors, "exp": sug.exposure_days, "pdate": sug.publish_date, "link": sug.item_url, + "img": sug.image_link, "mname": sug.metro_name, "mdist": sug.metro_distance, "gpb": sug.has_good_price_badge, diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 1c38ee2d..dc4ab1b7 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -47,6 +47,7 @@ from scraper_kit.providers.avito.detail import ( save_detail_enrichment, ) from scraper_kit.providers.avito.serp import AvitoScraper +from scraper_kit.snapshot_writer import upsert_listing_snapshot from sqlalchemy import text from sqlalchemy.orm import Session @@ -241,7 +242,7 @@ async def run_avito_detail_backfill( text( """ WITH ekb AS ( - SELECT id, source_url, 'ekb' AS city_scope + SELECT id, source_url, price_rub, 'ekb' AS city_scope FROM listings WHERE source = 'avito' AND detail_enriched_at IS NULL @@ -254,7 +255,7 @@ async def run_avito_detail_backfill( LIMIT CAST(:batch_size AS int) ), oblast AS ( - SELECT id, source_url, 'oblast' AS city_scope + SELECT id, source_url, price_rub, 'oblast' AS city_scope FROM listings WHERE source = 'avito' AND detail_enriched_at IS NULL @@ -264,9 +265,9 @@ async def run_avito_detail_backfill( ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST LIMIT CAST(:oblast_batch_size AS int) ) - SELECT id, source_url, city_scope FROM ekb + SELECT id, source_url, price_rub, city_scope FROM ekb UNION ALL - SELECT id, source_url, city_scope FROM oblast + SELECT id, source_url, price_rub, city_scope FROM oblast """ ), { @@ -442,6 +443,21 @@ async def run_avito_detail_backfill( text("UPDATE listings SET is_active = FALSE WHERE id = :id"), {"id": row["id"]}, ) + # #2674: 404 с площадки — самый достоверный сигнал снятия, + # фиксируем его в дневной истории (listings_snapshots.status + # был константой 'active' у всех строк, 394 299). Тот же + # SAVEPOINT, что и UPDATE флага: снимок без флага (или + # наоборот) невозможен. price_rub из snapshot-SELECT — + # .get() консервативен ради mock-снапшотов старых тестов. + gone_price = row.get("price_rub") + if gone_price is not None: + upsert_listing_snapshot( + db, + listing_id=row["id"], + price_rub=gone_price, + run_id=run_id, + status="closed", + ) except Exception: logger.warning( "avito_detail_backfill: run_id=%d failed to mark listing %s " diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index 1d1c2188..c45c217c 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -9,6 +9,8 @@ TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем. - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. - Строки НЕ удаляются -- история нужна для бэктеста (#667). +- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом + 'closed' за текущую дату -- дата снятия объявления перестаёт быть догадкой. Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*), @@ -41,21 +43,52 @@ logger = logging.getLogger(__name__) # только реальный скрейп). _ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"}) +# ── Снимок «снято» в дневной истории (#2674) ────────────────────────────────── +# listings_snapshots.status до этого фикса был константой 'active' у всех строк +# (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали +# литерал 'active', и это честно: там объявление ДЕЙСТВИТЕЛЬНО видели. А деактивация +# TTL-задачей не оставляла в истории вообще никакого следа. Из-за этого дата снятия +# объявления (лучший доступный сигнал «скорее всего продано») не запрашивалась из +# истории, а восстанавливалась на глаз: последний показ + предполагаемый срок жизни. +# +# Пишем снимок в ТОЙ ЖЕ транзакции, что и UPDATE флага: деактивация без снимка (или +# наоборот) невозможна по построению — один statement, data-modifying CTE. +# 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно +# одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются +# в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных. +# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена, +# ровно то, по чему объявление ушло с площадки. +# ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром, +# а вечером сработал TTL) — только переводим статус в 'closed', цену не переписываем. +_CLOSED_SNAPSHOT_TAIL = """ + INSERT INTO listings_snapshots + (listing_id, snapshot_date, run_id, price_rub, status, observed_at) + SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'closed', NOW() + FROM stale + ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET + status = 'closed', + observed_at = EXCLUDED.observed_at +""" + def _build_all_segments_sql(staleness_column: str) -> Any: """UPDATE без фильтра по сегменту: все сегменты для данного source. staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings, поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source, - :ttl_days) остаются param-binding — psycopg v3 safe (никаких :param::type). + :ttl_days, :run_id) остаются param-binding — psycopg v3 safe (никаких :param::type). """ return text( f""" - UPDATE listings - SET is_active = false - WHERE source = :listing_source - AND is_active = true - AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + WITH stale AS ( + UPDATE listings + SET is_active = false + WHERE source = :listing_source + AND is_active = true + AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + RETURNING id, price_rub + ) + {_CLOSED_SNAPSHOT_TAIL} """ ) @@ -68,12 +101,16 @@ def _build_segments_sql(staleness_column: str) -> Any: """ return text( f""" - UPDATE listings - SET is_active = false - WHERE source = :listing_source - AND is_active = true - AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) - AND listing_segment = ANY(CAST(:segments AS text[])) + WITH stale AS ( + UPDATE listings + SET is_active = false + WHERE source = :listing_source + AND is_active = true + AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + AND listing_segment = ANY(CAST(:segments AS text[])) + RETURNING id, price_rub + ) + {_CLOSED_SNAPSHOT_TAIL} """ ) @@ -111,9 +148,10 @@ def deactivate_stale_listings( свежесть = scraped_at (двигается только реальным скрейпом). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). - Один UPDATE в транзакции. Финализирует scrape_runs (mark_done / mark_failed). + Один statement в транзакции: UPDATE флага + снимок 'closed' в listings_snapshots + (data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed). - Returns {"deactivated": N} -- количество обновлённых строк. + Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками). Raises: ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, @@ -140,10 +178,15 @@ def deactivate_stale_listings( "listing_source": listing_source, "ttl_days": ttl_days, "segments": segments, + "run_id": run_id, } result = db.execute(_build_segments_sql(staleness_column), params) else: - params = {"listing_source": listing_source, "ttl_days": ttl_days} + params = { + "listing_source": listing_source, + "ttl_days": ttl_days, + "run_id": run_id, + } result = db.execute(_build_all_segments_sql(staleness_column), params) counters["deactivated"] = result.rowcount or 0 diff --git a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py index 47044bc2..f48df526 100644 --- a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py +++ b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py @@ -104,13 +104,40 @@ _SNAPSHOT_SQL = text( """ ) -# ── Event diff: price_change ────────────────────────────────────────────────── -# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с -# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась -# (обе NOT NULL, old <> 0) — пишем price_change. +# ── Event diff: все пять типов событий ──────────────────────────────────────── +# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с +# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE). # today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции). # p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607). # +# #2674: схема (079) знает пять типов событий, писатель умел один — price_change, +# 8288 строк; delisted/relisted/edited/first_seen — ноль за всё время. Снимок уже +# содержит ровно те поля, из которых остальные четыре выводятся, так что это не новые +# данные, а недописанные ветки того же сравнения: +# delisted — is_active был true, стал false; +# relisted — обратный переход; +# edited — payload_hash изменился, а цена нет (изменение цены уже описано +# отдельным событием price_change — дублировать его как «редактирование» +# значило бы считать одно изменение дважды). Прошлый хеш обязан быть +# непустым: md5(NULL) = NULL, и «payload появился впервые» — это не +# правка, а первое наблюдение; +# first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL). +# ВАЖНО про семантику delisted/relisted: is_active в снимке — derived-признак +# «last_seen_at свежее окна в FRESHNESS_WINDOW_DAYS дней», а НЕ «площадка удалила +# объявление». delisted здесь читается как «источник перестал наблюдаться», и обратный +# переход (источник снова попал в скрейп) поэтому нормален и част. Жёсткий сигнал +# снятия живёт в listings_snapshots.status='closed' (см. deactivate_stale_avito.py). +# +# JOIN → LEFT JOIN LATERAL: без LEFT источники без предыдущего снимка отбрасывались +# join'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется — +# LEFT JOIN LATERAL так же форсирует per-row индексный point-lookup по +# idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей. +# +# Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт +# до пяти строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это +# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто пять предикатов +# вместо одного. +# # #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM # listing_source_snapshots WHERE snapshot_date < CURRENT_DATE` и джойнился обычным JOIN. # Планировщик оценивает `today` в 1 строку (свежевставленные в этой же транзакции строки @@ -129,36 +156,82 @@ _SNAPSHOT_SQL = text( # — это внутренний план Postgres, не Python-итерация). # change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type) # в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным. +# +# NULLIF(p.price_rub, 0) в diff_percent обязателен: выражения VALUES вычисляются ДО +# фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже +# не спасает — без NULLIF первый же источник с нулевой прошлой ценой уронил бы весь +# прогон. Результат при этом тот же: строка с NULL-диффом не проходит e.fires. +# +# Внешний SELECT над data-modifying CTE считает вставленное ПО ТИПАМ (RETURNING отдаёт +# только реально вставленные строки, не съеденные ON CONFLICT), сразу в виде ключей +# счётчиков `_events` — писатель получает готовый dict без Python-агрегации. +# Ровно этот счётчик и показал бы четыре нуля из пяти, если бы существовал раньше. _EVENT_DIFF_SQL = text( """ WITH today AS ( - SELECT listing_source_id, price_rub + SELECT listing_source_id, price_rub, is_active, payload_hash FROM listing_source_snapshots WHERE snapshot_date = CURRENT_DATE + ), + inserted AS ( + INSERT INTO listing_source_events ( + listing_source_id, change_time, event_type, price_rub, diff_percent + ) + SELECT + t.listing_source_id, + now(), + e.event_type, + t.price_rub, + e.diff_percent + FROM today t + LEFT JOIN LATERAL ( + SELECT s.snapshot_date, s.price_rub, s.is_active, s.payload_hash + FROM listing_source_snapshots s + WHERE s.listing_source_id = t.listing_source_id + AND s.snapshot_date < CURRENT_DATE + ORDER BY s.snapshot_date DESC + LIMIT 1 + ) p ON true + CROSS JOIN LATERAL (VALUES + ( + 'first_seen', + NULL::numeric, + p.snapshot_date IS NULL + ), + ( + 'price_change', + round((t.price_rub - p.price_rub)::numeric + / NULLIF(p.price_rub, 0) * 100, 4), + t.price_rub IS NOT NULL + AND p.price_rub IS NOT NULL + AND p.price_rub <> 0 + AND t.price_rub <> p.price_rub + ), + ( + 'delisted', + NULL::numeric, + p.is_active AND NOT t.is_active + ), + ( + 'relisted', + NULL::numeric, + NOT p.is_active AND t.is_active + ), + ( + 'edited', + NULL::numeric, + p.payload_hash IS NOT NULL + AND t.payload_hash IS DISTINCT FROM p.payload_hash + AND t.price_rub IS NOT DISTINCT FROM p.price_rub + ) + ) AS e(event_type, diff_percent, fires) + WHERE e.fires + ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING + RETURNING event_type ) - INSERT INTO listing_source_events ( - listing_source_id, change_time, event_type, price_rub, diff_percent - ) - SELECT - t.listing_source_id, - now(), - 'price_change', - t.price_rub, - round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4) - FROM today t - JOIN LATERAL ( - SELECT s.price_rub - FROM listing_source_snapshots s - WHERE s.listing_source_id = t.listing_source_id - AND s.snapshot_date < CURRENT_DATE - ORDER BY s.snapshot_date DESC - LIMIT 1 - ) p ON true - WHERE t.price_rub IS NOT NULL - AND p.price_rub IS NOT NULL - AND p.price_rub <> 0 - AND t.price_rub <> p.price_rub - ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING + SELECT event_type || '_events' AS counter_key, count(*) AS n + FROM inserted + GROUP BY 1 """ ) @@ -166,12 +239,13 @@ _EVENT_DIFF_SQL = text( def snapshot_listing_sources( db: Session, run_id: int, params: dict[str, Any] | None = None ) -> dict[str, int]: - """Записать дневной снимок listing_sources + price_change-события. + """Записать дневной снимок listing_sources + события изменений. Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp). Два set-based statement'а в одной транзакции: 1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins. - 2. diff сегодняшней цены против последнего предыдущего снимка → price_change-события. + 2. diff сегодняшнего снимка против последнего предыдущего → события всех пяти + типов, которые знает схема 079 (#2674). Params (из default_params jsonb в scrape_schedules, #2607): budget_sec: float — SET LOCAL statement_timeout на транзакцию (default 900, @@ -183,11 +257,20 @@ def snapshot_listing_sources( Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. - Returns {"snapshotted": N, "price_change_events": M}. + Returns {"snapshotted": N, "_events": M} — по счётчику на каждый из + пяти типов событий схемы 079, всегда все пять ключей (тип, который за прогон не + сработал ни разу, честно показывает 0, а не пропадает из counters). """ params = params or {} budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC)) - counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0} + counters: dict[str, int] = { + "snapshotted": 0, + "price_change_events": 0, + "delisted_events": 0, + "relisted_events": 0, + "edited_events": 0, + "first_seen_events": 0, + } try: # statement_timeout НЕ принимает bind-параметр ($1/:name) — синтаксис Postgres SET # запрещает placeholder на этом месте (проверено вживую на проде: "syntax error at @@ -205,17 +288,15 @@ def snapshot_listing_sources( ) counters["snapshotted"] = snap_result.rowcount or 0 - event_result = db.execute(_EVENT_DIFF_SQL) - counters["price_change_events"] = event_result.rowcount or 0 + # Statement возвращает уже готовые пары (counter_key, n) по типам событий — + # dict(...) без Python-агрегации, набор ключей задан инициализацией counters + # выше, так что не сработавшие типы остаются нулями, а не исчезают. + event_rows = db.execute(_EVENT_DIFF_SQL).fetchall() + counters.update(dict(event_rows)) db.commit() runs_mod.mark_done(db, run_id, counters) - logger.info( - "snapshot_listing_sources run_id=%d done: snapshotted=%d price_change_events=%d", - run_id, - counters["snapshotted"], - counters["price_change_events"], - ) + logger.info("snapshot_listing_sources run_id=%d done: %s", run_id, counters) return counters except Exception as exc: logger.exception( diff --git a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py new file mode 100644 index 00000000..379aaca6 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py @@ -0,0 +1,362 @@ +"""#2674 — писатели наконец пишут то, что обещает схема. + +Три находки одного класса: колонка есть, писатель есть, тест на писателя зелёный, +а данные не появляются. Обычный юнит-тест такое не ловит по построению — он +проверяет то, что автор себе представлял. Ловится это сверкой «что схема обещает» +с «что писатель реально перечисляет», поэтому тесты ниже читают миграции и +сравнивают их с SQL писателя, а не повторяют его же список колонок. + +Числа с прода на 2026-08-05 (до фикса): + 1. house_suggestions — 25 055 строк, image_link/area_m2/rooms/floor/total_floors + заполнены у 0 из них (колонки с миграции 064, ~74 дня). + 2. listings_snapshots.status — 'active' у всех 394 704 строк при 55 448 реально + неактивных объявлений; 'closed' не писал никто и никогда. + 3. listing_source_events — 8288 строк, все price_change; delisted/relisted/ + edited/first_seen — ноль за всё время при 10 501/5 493/414 реальных переходах + в снимках только за последние 12 дней. + +БД и сеть замоканы — реального Postgres не нужно. +""" + +from __future__ import annotations + +import inspect +import json +import os +import re +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.avito.imv import _parse_suggestion + +from app.services import house_imv_backfill as hib +from app.tasks import deactivate_stale_avito as deact_mod +from app.tasks import listing_source_snapshot as snap_mod + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +# ── Общие хелперы: схема vs writer ──────────────────────────────────────────── + + +def _declared_columns(migration: str, table: str) -> set[str]: + """Имена колонок из CREATE TABLE IF NOT EXISTS ( ... ); в миграции.""" + body = migration.split(f"CREATE TABLE IF NOT EXISTS {table} (")[1].split("\n);")[0] + cols: set[str] = set() + for line in body.splitlines(): + m = re.match(r"\s+([a-z_]+)\s+[a-z]", line) + if m: + cols.add(m.group(1)) + return cols + + +def _insert_columns(sql: str, table: str) -> set[str]: + """Имена колонок из INSERT INTO
( ... ) VALUES.""" + m = re.search(rf"INSERT INTO {table}\s*\(([^)]*)\)", sql, re.S) + assert m is not None, f"не найден INSERT INTO {table}" + return {c.strip() for c in m.group(1).split(",") if c.strip()} + + +# ══ 1. Фотографии подсказок Avito IMV ═════════════════════════════════════════ + + +def test_suggestion_parser_keeps_image_link() -> None: + """imageLink из ответа площадки доезжает до модели, а не выбрасывается.""" + sugg = _parse_suggestion( + { + "id": 8000753763, + "title": "3-к. квартира, 61,6 м², 1/5 эт.", + "price": 7400000, + "imageLink": "https://80.img.avito.st/image/1/abc", + } + ) + assert sugg.image_link == "https://80.img.avito.st/image/1/abc" + # В raw_payload ссылку по-прежнему не дублируем — у неё теперь своя колонка. + assert "imageLink" not in (sugg.raw_payload or {}) + + +def test_suggestion_parser_derives_metrics_from_title() -> None: + """rooms/area_m2/floor/total_floors парсятся из title тем же путём, что у + placementHistory (колонки house_suggestions существуют с миграции 064).""" + sugg = _parse_suggestion( + {"id": 1, "title": "3-к. квартира, 61,6 м², 1/5 эт.", "price": 7400000} + ) + assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (3, 61.6, 1, 5) + + +def test_suggestion_parser_survives_unparsable_title() -> None: + """Нераспознанный заголовок → None'ы, а не исключение (строка всё равно пишется).""" + sugg = _parse_suggestion({"id": 2, "title": "Апартаменты", "price": 1}) + assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (None, None, None, None) + + +def test_live_fixture_suggestions_carry_image_link() -> None: + """Живой capture avito_imv_getdata.json: у подсказок реально есть imageLink.""" + data = json.loads((_FIXTURES / "avito_imv_getdata.json").read_text("utf-8")) + items = data["suggestions"]["items"] + parsed = [_parse_suggestion(raw) for raw in items] + assert parsed, "фикстура без подсказок — тест бессмыслен" + assert all(s.image_link for s in parsed) + + +def test_house_suggestions_insert_covers_every_declared_column() -> None: + """Regression-гейт на весь класс бага: INSERT обязан перечислять КАЖДУЮ колонку + house_suggestions из миграции 064 (кроме автоинкрементного id). + + Именно этот тест покраснел бы 74 дня назад: image_link (и заодно area_m2/rooms/ + floor/total_floors) объявлены схемой, но в запрос вставки не входили — 25 055 + строк с NULL. Тест не дублирует список колонок писателя, а сверяет его со схемой, + поэтому ловит и следующую забытую колонку. + """ + declared = _declared_columns( + (_SQL_DIR / "064_house_imv_phase_c.sql").read_text("utf-8"), "house_suggestions" + ) + written = _insert_columns(inspect.getsource(hib.save_imv_result), "house_suggestions") + assert ( + declared - {"id"} <= written + ), f"колонки без писателя: {sorted(declared - {'id'} - written)}" + + +def test_save_imv_result_binds_image_link_and_metrics() -> None: + """save_imv_result передаёт значения подсказки в bind-параметры (не только в SQL).""" + sugg = _parse_suggestion( + { + "id": 777, + "title": "2-к. квартира, 42 м², 4/5 эт.", + "price": 6300000, + "imageLink": "https://img/x.jpg", + } + ) + result = MagicMock( + cache_key="k", + recommended_price=1, + lower_price=1, + higher_price=1, + market_count=1, + raw_response=None, + placement_history=[], + suggestions=[sugg], + ) + params = { + "rooms": 2, + "area_m2": 42.0, + "floor": 4, + "floor_at_home": 5, + "house_type": "panel", + "renovation_type": "cosmetic", + "has_balcony": True, + "has_loggia": False, + } + db = MagicMock() + hib.save_imv_result(db, house_id=1, params=params, result=result) + + sugg_calls = [ + c for c in db.execute.call_args_list if "INSERT INTO house_suggestions" in str(c.args[0]) + ] + assert len(sugg_calls) == 1 + bound = sugg_calls[0].args[1] + assert bound["img"] == "https://img/x.jpg" + assert (bound["rooms"], bound["area"], bound["floor"], bound["total_floors"]) == (2, 42.0, 4, 5) + + +# ══ 2. Статус «снято» в дневной истории объявлений ════════════════════════════ + +_DEACT_SQL_BUILDERS = (deact_mod._build_all_segments_sql, deact_mod._build_segments_sql) + + +@pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS) +@pytest.mark.parametrize("column", sorted(deact_mod._ALLOWED_STALENESS_COLUMNS)) +def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, column: str) -> None: + """Деактивация и снимок 'closed' — один statement, значит одна транзакция. + + До #2674 задача только двигала флаг: в listings_snapshots не появлялось ничего, + и дата снятия объявления (лучший сигнал «скорее всего продано») восстанавливалась + на глаз из последнего показа + предполагаемого срока жизни. + """ + sql = str(build(column).text) + assert "SET is_active = false" in sql + assert "RETURNING id, price_rub" in sql + assert "INSERT INTO listings_snapshots" in sql + assert "'closed'" in sql + # Снимок пишется по строкам, которые вернул сам UPDATE, — не отдельной выборкой. + assert "FROM stale" in sql + # Идемпотентность: повторный прогон в те же сутки не падает на PK. + assert "ON CONFLICT (listing_id, snapshot_date) DO UPDATE" in sql + # psycopg v3: никаких :param::type. + assert not re.search(r":\w+::", sql) + + +class _FakeResult: + def __init__(self, rowcount: int) -> None: + self.rowcount = rowcount + + +class _FakeDB: + def __init__(self, rowcount: int = 0) -> None: + self._rowcount = rowcount + self.executed: list[tuple[Any, Any]] = [] + self.committed = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + self.executed.append((stmt, params)) + return _FakeResult(self._rowcount) + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: # pragma: no cover — путь ошибки тут не проверяется + pass + + +def test_deactivate_stale_listings_threads_run_id_into_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_id доезжает до снимка — провенанс «каким прогоном закрыто» не теряется.""" + monkeypatch.setattr(deact_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(deact_mod.runs_mod, "mark_failed", lambda *a, **k: None) + db = _FakeDB(rowcount=7) + + out = deact_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + run_id=555, + listing_source="avito", + ttl_days=10, + ) + + assert out == {"deactivated": 7} + assert len(db.executed) == 1, "деактивация и снимок обязаны быть одним statement'ом" + stmt, bound = db.executed[0] + assert "INSERT INTO listings_snapshots" in str(stmt) + assert bound is not None and bound["run_id"] == 555 + + +@pytest.mark.asyncio +async def test_avito_404_records_closed_snapshot() -> None: + """404 с площадки — самый достоверный сигнал снятия; он тоже попадает в историю.""" + from scraper_kit.avito_exceptions import AvitoListingGoneError + + from app.tasks.avito_detail_backfill import run_avito_detail_backfill + + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = [ + {"id": 42, "source_url": "/items/42", "price_rub": 5_000_000} + ] + db.execute.return_value = sel + fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False) + + with ( + patch("app.tasks.avito_detail_backfill.settings", fake_settings), + patch("app.tasks.avito_detail_backfill.AsyncSession", return_value=AsyncMock()), + patch("app.tasks.avito_detail_backfill.AvitoScraper"), + patch("app.tasks.avito_detail_backfill.runs_mod", MagicMock()), + patch("app.tasks.avito_detail_backfill.asyncio.sleep", new_callable=AsyncMock), + patch( + "app.tasks.avito_detail_backfill.fetch_detail", + AsyncMock(side_effect=AvitoListingGoneError("404 gone")), + ), + patch("app.tasks.avito_detail_backfill.upsert_listing_snapshot") as snap, + ): + result = await run_avito_detail_backfill(db, run_id=3, params={"budget_sec": 60}) + + assert result.gone == 1 + snap.assert_called_once() + assert snap.call_args.kwargs["status"] == "closed" + assert snap.call_args.kwargs["listing_id"] == 42 + assert snap.call_args.kwargs["price_rub"] == 5_000_000 + + +# ══ 3. Журнал событий объявлений ══════════════════════════════════════════════ + + +def _schema_event_types() -> set[str]: + """Пять типов из CHECK-констрейнта миграции 079 — источник правды.""" + sql = (_SQL_DIR / "079_listing_source_history.sql").read_text("utf-8") + check = sql.split("event_type IN (")[1].split(")")[0] + return set(re.findall(r"'([a-z_]+)'", check)) + + +def test_event_writer_covers_every_schema_event_type() -> None: + """Писатель обязан уметь КАЖДЫЙ тип, который разрешает CHECK схемы. + + До #2674 из пяти типов писался один (price_change, 8288 строк), остальные + четыре — ноль за всё время. Тест сверяет со схемой, а не с копией списка, + поэтому покраснеет и на шестом типе, добавленном в CHECK без писателя. + """ + declared = _schema_event_types() + assert len(declared) == 5, f"схема 079 изменилась: {sorted(declared)}" + sql = str(snap_mod._EVENT_DIFF_SQL.text) + missing = {t for t in declared if f"'{t}'" not in sql} + assert not missing, f"типы событий без писателя: {sorted(missing)}" + + +def test_first_seen_requires_left_join_and_derivations_use_snapshot_fields() -> None: + """Ветки выводятся из полей снимка, first_seen достижим только через LEFT JOIN. + + С обычным JOIN источник без предыдущего снимка отбрасывался джойном — событие + «первое появление» было недостижимо по построению. + """ + sql = str(snap_mod._EVENT_DIFF_SQL.text) + assert "LEFT JOIN LATERAL" in sql + assert "p.snapshot_date IS NULL" in sql # first_seen + assert "p.is_active AND NOT t.is_active" in sql # delisted + assert "NOT p.is_active AND t.is_active" in sql # relisted + assert "t.payload_hash IS DISTINCT FROM p.payload_hash" in sql # edited + # Снимок за сегодня обязан отдавать поля, из которых выводятся ветки. + assert "SELECT listing_source_id, price_rub, is_active, payload_hash" in sql + + +def test_price_change_division_guarded_by_nullif() -> None: + """VALUES вычисляется ДО фильтра e.fires → без NULLIF прогон падал бы на + первом источнике с нулевой прошлой ценой (предикат p.price_rub <> 0 не спасает).""" + sql = str(snap_mod._EVENT_DIFF_SQL.text) + assert "NULLIF(p.price_rub, 0)" in sql + + +class _EventFakeDB: + """Session-заглушка: SET LOCAL и snapshot дают rowcount, event-diff — пары счётчиков.""" + + def __init__(self, event_rows: list[tuple[str, int]]) -> None: + self._event_rows = event_rows + self.committed = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + result = MagicMock() + if "listing_source_events" in str(stmt): + result.fetchall.return_value = self._event_rows + else: + result.rowcount = 100 + return result + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: # pragma: no cover — путь ошибки тут не проверяется + pass + + +def test_counters_report_all_five_types_including_zeros(monkeypatch: pytest.MonkeyPatch) -> None: + """Счётчики прогона показывают все пять типов; не сработавший честно равен 0. + + Ровно этого счётчика не хватало, чтобы заметить четыре нуля из пяти за 66 дней. + """ + monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None) + + db = _EventFakeDB([("delisted_events", 10_501), ("first_seen_events", 600)]) + out = snap_mod.snapshot_listing_sources(db, run_id=1) # type: ignore[arg-type] + + assert out["delisted_events"] == 10_501 + assert out["first_seen_events"] == 600 + assert out["relisted_events"] == 0 + assert out["edited_events"] == 0 + assert out["price_change_events"] == 0 + # Ключ на каждый тип из схемы — иначе «ноль» неотличим от «типа нет в counters». + for event_type in _schema_event_types(): + assert f"{event_type}_events" in out diff --git a/tradein-mvp/backend/tests/test_listing_source_snapshot.py b/tradein-mvp/backend/tests/test_listing_source_snapshot.py index 98d49492..016ac2ed 100644 --- a/tradein-mvp/backend/tests/test_listing_source_snapshot.py +++ b/tradein-mvp/backend/tests/test_listing_source_snapshot.py @@ -104,9 +104,11 @@ def test_event_diff_is_set_based_lateral_not_python_loop() -> None: def test_event_diff_emits_price_change_with_diff_percent() -> None: assert "'price_change'" in _EVENT_DIFF_SQL - # diff_percent = (new-old)/old*100. + # diff_percent = (new-old)/old*100. NULLIF на знаменателе (#2674): выражения + # VALUES вычисляются ДО фильтра e.fires, поэтому предикат "p.price_rub <> 0" + # больше не защищает само деление. assert "(t.price_rub - p.price_rub)" in _EVENT_DIFF_SQL - assert "/ p.price_rub * 100" in _EVENT_DIFF_SQL + assert "/ NULLIF(p.price_rub, 0) * 100" in _EVENT_DIFF_SQL # Only when the price actually changed and old is a usable denominator. assert "t.price_rub <> p.price_rub" in _EVENT_DIFF_SQL assert "p.price_rub <> 0" in _EVENT_DIFF_SQL @@ -200,22 +202,30 @@ def test_migration_079_uses_psycopg_safe_sql() -> None: class _FakeResult: - def __init__(self, rowcount: int) -> None: + def __init__(self, rowcount: int, rows: list[tuple[str, int]] | None = None) -> None: self.rowcount = rowcount + self._rows = rows or [] + + def fetchall(self) -> list[tuple[str, int]]: + """Event-diff statement возвращает пары (counter_key, n) — см. #2674.""" + return self._rows class _FakeDB: """Minimal stand-in for a SQLAlchemy Session — records execute() calls, returns rowcounts.""" - def __init__(self, rowcounts: list[int]) -> None: + def __init__( + self, rowcounts: list[int], event_rows: list[tuple[str, int]] | None = None + ) -> None: self._rowcounts = list(rowcounts) + self._event_rows = event_rows or [] self.executed: list[Any] = [] self.committed = False self.rolled_back = False def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: self.executed.append((stmt, params)) - return _FakeResult(self._rowcounts.pop(0)) + return _FakeResult(self._rowcounts.pop(0), self._event_rows) def commit(self) -> None: self.committed = True @@ -225,7 +235,12 @@ class _FakeDB: def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: - """snapshot_listing_sources maps the two execute() rowcounts to its counters + marks done.""" + """snapshot_listing_sources maps the snapshot rowcount + per-type event rows to counters. + + #2674: event-diff больше не отдаёт один rowcount — внешний SELECT над + data-modifying CTE возвращает пары (counter_key, n) по типам событий, а + counters инициализированы всеми пятью ключами (не сработавший тип = честный 0). + """ marked: dict[str, Any] = {} monkeypatch.setattr( snap_mod.runs_mod, @@ -234,11 +249,18 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: ) monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None) - # rowcounts: SET LOCAL statement_timeout (ignored), snapshot upsert, event-diff insert. - db = _FakeDB(rowcounts=[0, 18355, 42]) + # rowcounts: SET LOCAL statement_timeout (ignored), snapshot upsert, event-diff. + db = _FakeDB(rowcounts=[0, 18355, 0], event_rows=[("price_change_events", 42)]) out = snap_mod.snapshot_listing_sources(db, run_id=99) # type: ignore[arg-type] - assert out == {"snapshotted": 18355, "price_change_events": 42} + assert out == { + "snapshotted": 18355, + "price_change_events": 42, + "delisted_events": 0, + "relisted_events": 0, + "edited_events": 0, + "first_seen_events": 0, + } assert db.committed is True assert len(db.executed) == 3 # First statement sets the per-transaction wall-clock budget (#2607). @@ -249,7 +271,7 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: assert params is not None and params["run_id"] == 99 # Run finalised via mark_done with the same counters. assert marked["run_id"] == 99 - assert marked["counters"] == {"snapshotted": 18355, "price_change_events": 42} + assert marked["counters"] == out # ── budget_sec / statement_timeout (#2607) ───────────────────────────────────── diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py index ec688bd9..1dc80f2d 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py @@ -141,9 +141,18 @@ class IMVSuggestion: title: str | None = None address: str | None = None price_rub: int | None = None + # rooms/area_m2/floor/total_floors парсятся из title (#2674) — тот же путь, что + # у IMVPlacementHistoryItem; колонки в house_suggestions есть с миграции 064. + rooms: int | None = None + area_m2: float | None = None + floor: int | None = None + total_floors: int | None = None exposure_days: int | None = None publish_date: date | None = None item_url: str | None = None + # image_link — ссылка на фото лота (raw imageLink). Колонка house_suggestions.image_link + # существует с 064, но до #2674 значение выбрасывалось парсером. + image_link: str | None = None metro_name: str | None = None metro_distance: str | None = None metro_color: str | None = None @@ -262,20 +271,28 @@ def _parse_placement_item(raw: dict[str, Any]) -> IMVPlacementHistoryItem: def _parse_suggestion(raw: dict[str, Any]) -> IMVSuggestion: """Парсит один элемент из suggestions.items.""" + title = raw.get("title") + parsed = _parse_title(title) metro = raw.get("metro") or {} colors: list[str] = metro.get("colors") or [] - # Фильтруем imageLink из raw_payload + # imageLink выносим в отдельное поле (колонка house_suggestions.image_link) и + # убираем из raw_payload, чтобы не хранить ссылку дважды. clean_raw = {k: v for k, v in raw.items() if k not in ("imageLink",)} return IMVSuggestion( ext_item_id=str(raw["id"]), - title=raw.get("title"), + title=title, address=raw.get("address"), price_rub=raw.get("price"), + rooms=parsed["rooms"], # type: ignore[arg-type] + area_m2=parsed["area_m2"], # type: ignore[arg-type] + floor=parsed["floor"], # type: ignore[arg-type] + total_floors=parsed["total_floors"], # type: ignore[arg-type] exposure_days=raw.get("exposure"), publish_date=_unix_to_date(raw.get("publishDate")), item_url=raw.get("itemLink"), + image_link=raw.get("imageLink"), metro_name=metro.get("name"), metro_distance=metro.get("distance"), metro_color=colors[0] if colors else None, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/snapshot_writer.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/snapshot_writer.py index 624637bd..784eeef9 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/snapshot_writer.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/snapshot_writer.py @@ -4,11 +4,16 @@ PRIMARY KEY (listing_id, snapshot_date) — максимум 1 snapshot в сутки. ON CONFLICT DO UPDATE — берём последний за день (перезаписываем при повторном run'е). -Используется двумя путями: +Используется тремя путями: 1. SERP scrape (save_listings в base.py) — записывает цену + позицию в выдаче - каждый раз когда listing появляется в поиске. + каждый раз когда listing появляется в поиске (status='active'). 2. Detail backfill (save_detail_enrichment в cian_detail.py) — записывает цену - из detail-страницы для listings которые раньше не имели snapshot'а. + из detail-страницы для listings которые раньше не имели snapshot'а (status='active'). + 3. Снятие объявления (#2674): avito_detail_backfill при 404 с площадки пишет + status='closed'. Второй писатель 'closed' — deactivate_stale_listings, он + набирает тысячи строк за прогон и пишет их одним set-based statement'ом + (data-modifying CTE в app/tasks/deactivate_stale_avito.py), не через этот + per-row хелпер. """ from __future__ import annotations From 3e1b9a8b0de94dbf526dbaefcf2a2b3a8ab073b8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 02:53:26 +0500 Subject: [PATCH 018/130] =?UTF-8?q?fix(tradein):=20=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=20=D1=82=D0=B0=D0=BA=D1=82=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=A1=D0=B1=D0=B5=D1=80=D0=98?= =?UTF-8?q?=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=20=E2=80=94=20=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20ERROR=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BB=20=D0=B1=D1=8B=20=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D1=82=D1=80=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=B9=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #2681 опровергло исходную посылку по СберИндексу, и это подтвердилось на моих же числах (все 24 прогона монитора, read-only): 13-16.07 alert=1 age 73..76 latest=май 17.07 alert=0 age 46 latest=июнь ← день загрузки 18-31.07 alert=0 age 47..60 01-05.08 alert=1 age 61..65 Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст считается от первого числа покрытого месяца → пол 46, потолок 74, порог 60 ВНУТРИ диапазона. Тревога срабатывала 14 суток из 28 без всякого застоя источника: девять срабатываний были замером нашего собственного такта. Поднятие до ERROR без этой правки завело бы ежедневное ложное событие две недели в месяц. Миграция 212 переводит sber_index_pull на недельный такт (потолок ≈53 при пороге 60, запас 7 суток) вместо поднятия порога до 75 (запас 1 сутки — ломается от любого сдвига окна). Цена: 9 запросов в неделю вместо 9 в 28 дней к публичному sberindex.ru/api/sowa; прогон 4 секунды, 0 ошибок за всю историю. Дополнительно по ревью: - поллер Росреестра: ветка «файл найден в листинге, но HEAD не отдал zip» → ERROR (ровно поведение старой Bitrix-заглушки) + вписана в таблицу уровней; - тестовый харнесс закрывает клиент событий (фоновый поток на каждый тест). Refs #2674 --- .../backend/app/services/rosreestr_poll.py | 13 +++- .../app/tasks/sber_freshness_monitor.py | 33 ++++++--- .../data/sql/212_sber_index_pull_weekly.sql | 68 +++++++++++++++++++ .../tests/test_alerts_become_events.py | 32 ++++++++- .../tests/test_sber_freshness_monitor.py | 35 ++++++++++ 5 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql diff --git a/tradein-mvp/backend/app/services/rosreestr_poll.py b/tradein-mvp/backend/app/services/rosreestr_poll.py index 3fa293b0..3d278acd 100644 --- a/tradein-mvp/backend/app/services/rosreestr_poll.py +++ b/tradein-mvp/backend/app/services/rosreestr_poll.py @@ -55,6 +55,11 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу - Портал ответил не-200 на листинг каталога/папки → ERROR. Каталог — единственная опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча врал целыми кварталами. Такое обязано быть событием. + - Файл датасета НАЙДЕН в листинге, но HEAD не отдал zip / размер ниже порога → + ERROR. Тот же класс: это ровно поведение старой Bitrix-заглушки (200 + text/html). + Ветка может сработать легитимно (файл выложили в листинг раньше, чем докачали), + но цена асимметрична — ложное срабатывание стоит одного события в месяц (такт + 28 дней), пропуск стоит квартала молчания. - Таймаут / сетевая ошибка → WARNING, как раньше. Это транспортный блип раз в месяц (такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный deals_freshness_monitor ERROR-ом по max(deal_date). @@ -322,7 +327,13 @@ async def check_new_quarter_available( ) return True - logger.info( + # ERROR (#2674, ревью PR #2681): файл ЕСТЬ в листинге, но HEAD отдал не zip + # либо размер ниже порога — это буквально тот сбой, из-за которого поллер уже + # врал (Bitrix-заглушка отвечала 200 с text/html вместо архива, см. "ИСТОРИЯ"). + # Ветка может сработать и легитимно — файл появился в листинге раньше, чем + # докачался, — но цена асимметрична: такт 28 дней, значит ложное срабатывание + # стоит максимум одного события в месяц, а пропуск стоит квартала молчания. + logger.error( "rosreestr_poll: Q%d %d file found (%s) but failed availability check " "(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, " "treating as unavailable", diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 22193491..487179d5 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -14,21 +14,34 @@ #2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING -событием НЕ становится: на проде монитор отработал 24 раза, из них 9 со -staleness-вердиктом — и ни одного события. Бенчмарк цен участвует в сверке наших -медиан, его застой — сбой, а не наблюдение. Сосед по конструкции -(deals_freshness_monitor) писал ERROR с самого начала — расходилась только эта -джоба. +событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его +застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor) +писал ERROR с самого начала — расходилась только эта джоба. + +ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов +монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего +собственного такта загрузки: + 13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65 + 17.07 alert=0 age 46 latest=июнь (день загрузки) +Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же +считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок +46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать +такое до ERROR без починки такта значило бы завести ежедневное ложное событие на +две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ +такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает +«источник/загрузка встали», а не «мы давно не ходили». Порог алерта (документирование выбора): Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д). Монитор: age > sber_index_max_age_days + lag_allowance. lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг - публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл - ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог: - 35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет - (иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев - → алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1. + публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл + ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток. + Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста + ~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл + поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри + рабочего диапазона — если такт загрузки будут менять, пересчитай потолок + (пол + interval_days) и сверь с 60. Задача синхронная (DB-only, один SELECT max(period_month)) — запускается kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в diff --git a/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql b/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql new file mode 100644 index 00000000..07aaea2e --- /dev/null +++ b/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql @@ -0,0 +1,68 @@ +-- 212_sber_index_pull_weekly.sql +-- sber_index_pull: такт 28 дней → 7. Ревью PR #2681 (#2674). +-- +-- ПОЧЕМУ. Монитор sber_freshness_monitor алертил при age > 60д +-- (sber_index_max_age_days 35 + lag_allowance 25). Прод-разбор всех 24 прогонов +-- монитора (read-only, 2026-08-06, scrape_runs.counters) показал ПИЛУ, а не застой: +-- +-- 13-16.07 alert=1 age 73,74,75,76 latest_month=5 (май) +-- 17.07 alert=0 age 46 latest_month=6 ← день загрузки +-- 18-31.07 alert=0 age 47..60 latest_month=6 +-- 01-05.08 alert=1 age 61..65 latest_month=6 +-- +-- Механика: загрузка ходила раз в 28 дней и приносила период на месяц новее, а +-- возраст считается от ПЕРВОГО ЧИСЛА покрытого месяца. Значит в момент самой +-- свежей загрузки возраст уже ~46 (07-17 минус 06-01), к следующей дорастает до +-- 46+28=74, и порог 60 лежит ВНУТРИ [46, 74] — тревога пересекала его каждый +-- цикл, 14 суток из 28. Девять срабатываний, поданных в #2674 как улика застоя +-- бенчмарка, — это замер НАШЕГО СОБСТВЕННОГО ТАКТА. После #2681 (WARNING → ERROR) +-- это стало бы ежедневным событием две недели в месяц, гаснущим само собой — +-- ровно та ложная тревога, которая приучает не читать алерты. +-- +-- ПОЧЕМУ ТАКТ, А НЕ ПОРОГ. Рассматривались два варианта: +-- (A) поднять lag_allowance 25 → 40 (порог 75 против потолка 74). Запас ОДИН +-- день: любой сдвиг окна/пропуск прогона на сутки — и ложная тревога +-- возвращается. Порог при этом продолжает кодировать наш такт, а не +-- поведение источника. Отклонено. +-- (B) ЭТА миграция: такт 28 → 7. Потолок возраста становится floor+7 ≈ 53 при +-- том же пороге 60 — запас 7 суток, т.е. один пропущенный недельный цикл +-- поглощается, два подряд дают тревогу (и это уже осмысленная тревога). +-- Порог 60 начинает означать ИМЕННО «Сбер перестал публиковать / загрузка +-- сломалась», а не «мы давно не ходили». +-- +-- ЦЕНА. pull_sber_indices делает SBER_REF_AREAS (3: 643/66/77) × SBER_DASHBOARDS +-- (3) = 9 GET-запросов к публичному неавторизованному sberindex.ru/api/sowa, без +-- пауз в цикле; прод-прогон 2026-07-17 занял 4 секунды (counters.duration_sec=4, +-- errors=0, upserted=639). Было 9 запросов / 28 дней, стало 9 / 7 дней = 36 в +-- месяц. Это тот же эндпоинт, который дёргают сами дашборды Сбера при каждом +-- открытии страницы; лимитов/бана на нём за всю историю прогонов не наблюдалось +-- (0 ошибок в 6 прогонах). Риск нагрузки считаем отсутствующим. +-- +-- ПОБОЧНО. Оценщик имеет СВОЙ per-estimate guard свежести с порогом +-- settings.sber_index_max_age_days=35. Он пробивается всегда, потому что возраст +-- стартует с ~46. Недельный такт сокращает НАШУ задержку обнаружения с ≤28 суток +-- до ≤7, то есть возраст = (лаг публикации Сбера) + ≤7 вместо + ≤28. Уйдёт ли он +-- под 35 — зависит от того, когда Сбер реально публикует месяц (по нашим данным +-- лаг публикации ≤46 и ≥31 суток, точнее по имеющимся прогонам не определить), +-- поэтому НЕ обещаем починку этого guard'а, только снятие нашей части задержки. +-- +-- next_run_at подтягиваем на ближайшее окно (05:00-06:00 UTC): без этого правка +-- default_params начнёт действовать только после уже запланированного прогона +-- 2026-08-14, а до тех пор ложная тревога продолжала бы идти каждый день. +-- LEAST() — чтобы повторное применение НИКОГДА не отодвигало прогон дальше. +-- +-- Идемпотентно: jsonb-конкатенация + LEAST, повторный прогон безопасен. +-- Кода не меняет: interval_days читается kit-планировщиком из default_params +-- (orchestration/scheduler.py::_defer_next_run_at, params.get("interval_days", 1)). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) || '{"interval_days": 7}'::jsonb, + next_run_at = LEAST( + next_run_at, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC' + ) +WHERE source = 'sber_index_pull'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py index a58d5910..ae084d93 100644 --- a/tradein-mvp/backend/tests/test_alerts_become_events.py +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -66,7 +66,11 @@ def glitchtip_events() -> Iterator[list[dict[str, Any]]]: ) with sentry_sdk.isolation_scope() as scope: scope.set_client(client) - yield events + try: + yield events + finally: + # Иначе на каждый тест остаётся фоновый поток транспорта. + client.close() def event_texts(events: list[dict[str, Any]]) -> list[str]: @@ -304,6 +308,32 @@ async def test_rosreestr_broken_index_becomes_event() -> None: assert any("unexpected HTTP 503" in t for t in event_texts(events)) +async def test_rosreestr_stub_instead_of_zip_becomes_event() -> None: + """Файл есть в листинге, но HEAD отдал заглушку — тот сбой, из-за которого уже врали. + + Ровно поведение старой Bitrix-заглушки: HTTP 200 + text/html вместо архива. + """ + index_html = 'q' + folder_html = 'f' + client = MagicMock() + client.get = AsyncMock( + side_effect=[ + httpx.Response(200, text=index_html), + httpx.Response(200, text=folder_html), + ] + ) + client.head = AsyncMock( + return_value=httpx.Response( + 200, text="stub", headers={"content-type": "text/html", "content-length": "512"} + ) + ) + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available(client, 2026, 3) + + assert available is False + assert any("soft-404 guard" in t for t in event_texts(events)) + + async def test_rosreestr_quarter_not_published_is_silent() -> None: """Каталог жив, папки квартала ещё нет — самый частый прогон, событий быть не должно.""" with glitchtip_events() as events: diff --git a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py index 114322af..726d991e 100644 --- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py +++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py @@ -28,6 +28,7 @@ from app.tasks import sber_freshness_monitor as mon _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql" +_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql" # max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12). _MAY_2026 = date(2026, 5, 1) @@ -218,6 +219,40 @@ def test_migration_180_no_psycopg_trap() -> None: assert not re.search(r":\w+::", sql) +# ── Миграция 212: такт загрузки не должен пересекать порог монитора ─────────── +# +# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74 +# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника. +# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора. + + +def test_migration_212_makes_pull_cadence_weekly() -> None: + sql = _MIGRATION_212.read_text("utf-8") + assert "sber_index_pull" in sql + assert '"interval_days": 7' in sql + assert "BEGIN;" in sql and "COMMIT;" in sql + assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type) + + +def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None: + """Инвариант: пол возраста + такт загрузки < порога монитора. + + Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог = + sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 — запас + 7 суток. При прежних 28: 46+28=74 > 60 — тревога каждый цикл, что и наблюдали. + """ + interval_days = int( + re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) + ) + observed_floor_days = 46 + threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS + assert observed_floor_days + interval_days < threshold, ( + f"такт {interval_days}д даёт потолок возраста " + f"{observed_floor_days + interval_days}д при пороге {threshold}д — " + "монитор снова будет мерить наш такт, а не застой источника" + ) + + # ── Регистрация в kit registry ───────────────────────────────────────────────── From ab01f7cc48562876e45e763c8bcfc09318de1ed6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 03:01:05 +0500 Subject: [PATCH 019/130] =?UTF-8?q?fix(tradein):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BC=D1=8B=D0=B5=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F,=20=D1=80=D0=B0=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=C2=AB=D1=81=D0=BD=D1=8F=D1=82=D0=BE=C2=BB=20=D0=B8=20=C2=AB?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D1=83=D1=85=D0=BB=D0=BE=C2=BB=20(#2674?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #2682 нашло контрольную группу в наших же данных. Перепроверено собственными запросами к проду — сходится, местами хуже заявленного. 1. delisted/relisted УБРАНЫ из писателя событий. Покрытие обхода за 14-18.07: domklik 99.9-100%, yandex 34-43%, cian 21-27%, avito 1.6-3.4%. Переходы за те же дни: domklik — снятий 1/2/0/2/4 в сутки и возвратов РОВНО 0 все пять суток; yandex — снятий 343-433 в сутки. Тот же обход, тот же день, разница только в покрытии: событие рождается тем, что скрейпер снова дошёл, а не тем, что объявление вернулось. Подтверждения: avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против контрольной ставки 1-4 (точность ≈4%); 4705 возвратов из 5493 за 12 дней (85.7%) — это 2-3.08, два дня после возобновления обхода. Сужение окна свежести сделало бы хуже (больше флапаний). Журнал из догадок хуже пустого журнала — не пишем. is_active убран из запроса целиком. Гейт-тест ослаблен до трёх типов + новый гейт «невыводимые НЕ пишутся». 2. TTL-путь пишет 'stale', а не 'closed'. Прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая «дата продажи» одной датой. 'closed' остаётся только за 404: там ответила площадка. CHECK на колонке нет, миграция 212 обновляет только COMMENT. 3. change_time усечён до суток (date_trunc). С now() UNIQUE(source, change_time, type) работал только внутри прогона: второй прогон в те же сутки (2 августа их было два) давал дубли. Теперь заявленная идемпотентность действительно работает. 4. Комнатность в разборе заголовка стала необязательной: 1991 заголовок из 25 055 (7.9%) — «Квартира-студия, 34,2 м², 9/10 эт.», обязательная группа роняла match и обнуляла все четыре поля. Чинит обоих писателей сразу (house_suggestions + house_placement_history, там 8.8% без площади). Студия → rooms=0 по конвенции kit'а, а не None. Фальсификация: вернуть delisted — 1 красный; 'closed' на TTL-пути — 6; обязательная комнатность — 2; now() вместо date_trunc — 1. --- .../app/tasks/deactivate_stale_avito.py | 33 +++-- .../app/tasks/listing_source_snapshot.py | 73 ++++++----- .../212_listings_snapshots_status_vocab.sql | 29 +++++ .../tests/test_2674_writers_honor_schema.py | 121 ++++++++++++++---- .../tests/test_listing_source_snapshot.py | 2 - .../src/scraper_kit/providers/avito/imv.py | 16 ++- 6 files changed, 199 insertions(+), 75 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/212_listings_snapshots_status_vocab.sql diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index c45c217c..77a705bf 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -10,7 +10,8 @@ - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. - Строки НЕ удаляются -- история нужна для бэктеста (#667). - #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом - 'closed' за текущую дату -- дата снятия объявления перестаёт быть догадкой. + 'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка + ответила 404) пишет только avito_detail_backfill: смешивать факт с догадкой дорого. Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*), @@ -43,7 +44,7 @@ logger = logging.getLogger(__name__) # только реальный скрейп). _ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"}) -# ── Снимок «снято» в дневной истории (#2674) ────────────────────────────────── +# ── Снимок «протухло» в дневной истории (#2674) ─────────────────────────────── # listings_snapshots.status до этого фикса был константой 'active' у всех строк # (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали # литерал 'active', и это честно: там объявление ДЕЙСТВИТЕЛЬНО видели. А деактивация @@ -51,22 +52,32 @@ _ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"}) # объявления (лучший доступный сигнал «скорее всего продано») не запрашивалась из # истории, а восстанавливалась на глаз: последний показ + предполагаемый срок жизни. # +# ПОЧЕМУ 'stale', А НЕ 'closed'. Эта задача НЕ знает, что объявление снято, — она +# знает только, что МЫ его N суток не видели, а это разные факты, когда TTL короче +# простоя обхода. Замер: прогон по домклику 02.08 снял 6131 объявление за раз (TTL +# 14 суток против 12 суток простоя обхода) — с общим статусом это были бы 6131 +# фальшивая «дата продажи» одной датой. Продукт про цены, смешивать факт с догадкой +# дорого. Поэтому: +# 'closed' — только путь 404: площадка ответила «нет» (avito_detail_backfill); +# 'stale' — этот путь: «мы N суток не смотрели». +# Дата всё равно фиксируется, но читатель отличает одно от другого. Ограничения +# CHECK на колонке нет (проверено на проде), миграция не нужна — только COMMENT. +# # Пишем снимок в ТОЙ ЖЕ транзакции, что и UPDATE флага: деактивация без снимка (или # наоборот) невозможна по построению — один statement, data-modifying CTE. # 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно # одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются # в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных. -# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена, -# ровно то, по чему объявление ушло с площадки. +# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена. # ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром, -# а вечером сработал TTL) — только переводим статус в 'closed', цену не переписываем. -_CLOSED_SNAPSHOT_TAIL = """ +# а вечером сработал TTL) — только переводим статус в 'stale', цену не переписываем. +_STALE_SNAPSHOT_TAIL = """ INSERT INTO listings_snapshots (listing_id, snapshot_date, run_id, price_rub, status, observed_at) - SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'closed', NOW() + SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'stale', NOW() FROM stale ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET - status = 'closed', + status = 'stale', observed_at = EXCLUDED.observed_at """ @@ -88,7 +99,7 @@ def _build_all_segments_sql(staleness_column: str) -> Any: AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) RETURNING id, price_rub ) - {_CLOSED_SNAPSHOT_TAIL} + {_STALE_SNAPSHOT_TAIL} """ ) @@ -110,7 +121,7 @@ def _build_segments_sql(staleness_column: str) -> Any: AND listing_segment = ANY(CAST(:segments AS text[])) RETURNING id, price_rub ) - {_CLOSED_SNAPSHOT_TAIL} + {_STALE_SNAPSHOT_TAIL} """ ) @@ -148,7 +159,7 @@ def deactivate_stale_listings( свежесть = scraped_at (двигается только реальным скрейпом). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). - Один statement в транзакции: UPDATE флага + снимок 'closed' в listings_snapshots + Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots (data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed). Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками). diff --git a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py index f48df526..81dcbf16 100644 --- a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py +++ b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py @@ -104,29 +104,40 @@ _SNAPSHOT_SQL = text( """ ) -# ── Event diff: все пять типов событий ──────────────────────────────────────── +# ── Event diff: три выводимых типа событий из пяти в схеме ──────────────────── # Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с # самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE). # today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции). # p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607). # # #2674: схема (079) знает пять типов событий, писатель умел один — price_change, -# 8288 строк; delisted/relisted/edited/first_seen — ноль за всё время. Снимок уже -# содержит ровно те поля, из которых остальные четыре выводятся, так что это не новые -# данные, а недописанные ветки того же сравнения: -# delisted — is_active был true, стал false; -# relisted — обратный переход; +# 8288 строк. Дописаны два: # edited — payload_hash изменился, а цена нет (изменение цены уже описано # отдельным событием price_change — дублировать его как «редактирование» # значило бы считать одно изменение дважды). Прошлый хеш обязан быть # непустым: md5(NULL) = NULL, и «payload появился впервые» — это не # правка, а первое наблюдение; # first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL). -# ВАЖНО про семантику delisted/relisted: is_active в снимке — derived-признак -# «last_seen_at свежее окна в FRESHNESS_WINDOW_DAYS дней», а НЕ «площадка удалила -# объявление». delisted здесь читается как «источник перестал наблюдаться», и обратный -# переход (источник снова попал в скрейп) поэтому нормален и част. Жёсткий сигнал -# снятия живёт в listings_snapshots.status='closed' (см. deactivate_stale_avito.py). +# +# delisted и relisted НЕ ПИШУТСЯ НАМЕРЕННО — они НЕ ВЫВОДИМЫ из наших данных. +# is_active в снимке — derived-признак «last_seen_at свежее FRESHNESS_WINDOW_DAYS», +# то есть «мы видели», а не «объявление есть на площадке». При покрытии обхода 10-35% +# такой переход рождается тем, что скрейпер СНОВА ДОШЁЛ до источника, а не тем, что +# объявление вернулось/ушло. Контрольная группа в наших же данных (14-18.07): +# domklik, покрытие 99.9-100%: снятий 1/2/0/2/4 в сутки, возвратов — РОВНО 0 все дни; +# yandex, покрытие 34-43%: снятий 343-433 в сутки, возвратов до 155. +# Тот же обход, тот же день — разница только в покрытии. Отсюда же всплески: +# avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против контрольной +# ставки 1-4, точность события ≈4%; 4705 «возвратов» из 5493 за 12 дней (86%) — это +# два дня после возобновления обхода 2-3.08. +# Сузить окно свежести НЕ поможет — станет хуже (больше флапаний); окно шире +# максимального интервала повторного визита обессмысливает само событие. +# Честный ответ схеме — не писать эти два типа, а не наполнять журнал догадками. +# Единственный жёсткий сигнал снятия — 404 при поштучном обходе, он пишется в +# listings_snapshots.status='closed' (avito_detail_backfill). +# +# Оставшиеся три события утверждают факты о НАШИХ СОБСТВЕННЫХ строках («появился новый +# источник», «хеш изменился при той же цене», «цена другая»), а не о поведении площадки. # # JOIN → LEFT JOIN LATERAL: без LEFT источники без предыдущего снимка отбрасывались # join'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется — @@ -134,8 +145,8 @@ _SNAPSHOT_SQL = text( # idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей. # # Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт -# до пяти строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это -# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто пять предикатов +# до трёх строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это +# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто три предиката # вместо одного. # # #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM @@ -154,8 +165,13 @@ _SNAPSHOT_SQL = text( # # Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL # — это внутренний план Postgres, не Python-итерация). -# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type) -# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным. +# +# change_time = date_trunc('day', now()), а НЕ now() (#2674): с now() уникальность +# UNIQUE(listing_source_id, change_time, event_type) работала только ВНУТРИ прогона — +# второй прогон в те же сутки перезаписывал сегодняшний снимок, предикаты срабатывали +# заново с другим временем и давали дубли (2 августа таких прогонов было два). +# Суточная гранулярность честнее для суточного же сравнения и включает заявленную +# идемпотентность: ON CONFLICT DO NOTHING теперь действительно гасит повтор за день. # # NULLIF(p.price_rub, 0) в diff_percent обязателен: выражения VALUES вычисляются ДО # фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже @@ -169,7 +185,7 @@ _SNAPSHOT_SQL = text( _EVENT_DIFF_SQL = text( """ WITH today AS ( - SELECT listing_source_id, price_rub, is_active, payload_hash + SELECT listing_source_id, price_rub, payload_hash FROM listing_source_snapshots WHERE snapshot_date = CURRENT_DATE ), @@ -179,13 +195,13 @@ _EVENT_DIFF_SQL = text( ) SELECT t.listing_source_id, - now(), + date_trunc('day', now()), e.event_type, t.price_rub, e.diff_percent FROM today t LEFT JOIN LATERAL ( - SELECT s.snapshot_date, s.price_rub, s.is_active, s.payload_hash + SELECT s.snapshot_date, s.price_rub, s.payload_hash FROM listing_source_snapshots s WHERE s.listing_source_id = t.listing_source_id AND s.snapshot_date < CURRENT_DATE @@ -207,16 +223,6 @@ _EVENT_DIFF_SQL = text( AND p.price_rub <> 0 AND t.price_rub <> p.price_rub ), - ( - 'delisted', - NULL::numeric, - p.is_active AND NOT t.is_active - ), - ( - 'relisted', - NULL::numeric, - NOT p.is_active AND t.is_active - ), ( 'edited', NULL::numeric, @@ -244,8 +250,9 @@ def snapshot_listing_sources( Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp). Два set-based statement'а в одной транзакции: 1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins. - 2. diff сегодняшнего снимка против последнего предыдущего → события всех пяти - типов, которые знает схема 079 (#2674). + 2. diff сегодняшнего снимка против последнего предыдущего → три события, + выводимые из наших данных (#2674). delisted/relisted схема разрешает, но + они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL. Params (из default_params jsonb в scrape_schedules, #2607): budget_sec: float — SET LOCAL statement_timeout на транзакцию (default 900, @@ -258,16 +265,14 @@ def snapshot_listing_sources( Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. Returns {"snapshotted": N, "_events": M} — по счётчику на каждый из - пяти типов событий схемы 079, всегда все пять ключей (тип, который за прогон не - сработал ни разу, честно показывает 0, а не пропадает из counters). + трёх пишущихся типов, всегда все три ключа (тип, который за прогон не сработал + ни разу, честно показывает 0, а не пропадает из counters). """ params = params or {} budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC)) counters: dict[str, int] = { "snapshotted": 0, "price_change_events": 0, - "delisted_events": 0, - "relisted_events": 0, "edited_events": 0, "first_seen_events": 0, } diff --git a/tradein-mvp/backend/data/sql/212_listings_snapshots_status_vocab.sql b/tradein-mvp/backend/data/sql/212_listings_snapshots_status_vocab.sql new file mode 100644 index 00000000..fdd27346 --- /dev/null +++ b/tradein-mvp/backend/data/sql/212_listings_snapshots_status_vocab.sql @@ -0,0 +1,29 @@ +-- 212_listings_snapshots_status_vocab.sql +-- #2674 — словарь listings_snapshots.status стал трёхзначным: 'stale' ≠ 'closed'. +-- +-- ПРОБЛЕМА: комментарий колонки (016) обещал два значения — 'active' / 'closed' — +-- и при этом 'closed' не писал никто и никогда: status был константой 'active' у +-- всех 394 704 строк при 55 448 реально неактивных объявлениях. Писатель «снято» +-- появился в #2674, но одним значением обойтись нельзя: +-- - путь 404 (avito_detail_backfill) ЗНАЕТ, что объявления нет: площадка ответила; +-- - путь TTL (deactivate_stale_listings) знает только, что МЫ N суток не смотрели. +-- Замер: прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток +-- против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая «дата +-- продажи» одной датой. Продукт про цены: смешивать факт с догадкой дорого. +-- +-- ДЕЛАЕТ: только обновляет COMMENT — сама колонка `text` без CHECK, DDL не нужен. +-- CHECK намеренно НЕ добавляем: 394 704 существующие строки валидны, а жёсткий +-- словарь на историческую таблицу — деструктивный риск ради нулевой выгоды. +-- +-- Idempotent: COMMENT ON COLUMN — безусловная перезапись, безопасно повторно. +-- Apply after: 211_sales_vs_listings_segment_guard.sql + +BEGIN; + +COMMENT ON COLUMN listings_snapshots.status IS + '''active'' = объявление видели в прогоне. ' + '''closed'' = площадка ответила 404 на поштучном обходе (жёсткий факт снятия). ' + '''stale'' = TTL-деактивация: мы N суток не смотрели (догадка, НЕ дата продажи). ' + 'NULL = неизвестно. Словарь расширен в #2674 — до него писалось только ''active''.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py index 379aaca6..188b185d 100644 --- a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py +++ b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py @@ -6,14 +6,18 @@ с «что писатель реально перечисляет», поэтому тесты ниже читают миграции и сравнивают их с SQL писателя, а не повторяют его же список колонок. -Числа с прода на 2026-08-05 (до фикса): +Числа с прода на 2026-08-05/06 (до фикса): 1. house_suggestions — 25 055 строк, image_link/area_m2/rooms/floor/total_floors заполнены у 0 из них (колонки с миграции 064, ~74 дня). 2. listings_snapshots.status — 'active' у всех 394 704 строк при 55 448 реально - неактивных объявлений; 'closed' не писал никто и никогда. - 3. listing_source_events — 8288 строк, все price_change; delisted/relisted/ - edited/first_seen — ноль за всё время при 10 501/5 493/414 реальных переходах - в снимках только за последние 12 дней. + неактивных объявлений; ни 'closed', ни 'stale' не писал никто и никогда. + 3. listing_source_events — 8288 строк, все price_change; edited/first_seen — + ноль за всё время. + +Отдельный класс тестов — гейты на то, что писатель НЕ пишет: delisted/relisted схема +разрешает, но при покрытии обхода 10-35% они неотличимы от «скрейпер снова дошёл» +(контроль — домклик со 100% покрытием: 0 возвратов за 5 суток), а TTL-путь не имеет +права называть протухание снятием. Журнал и история из догадок хуже пустых. БД и сеть замоканы — реального Postgres не нужно. """ @@ -96,6 +100,26 @@ def test_suggestion_parser_survives_unparsable_title() -> None: assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (None, None, None, None) +def test_studio_title_keeps_area_and_floors() -> None: + """Студия не роняет разбор целиком: 1991 заголовок из 25 055 (7.9%) — без комнатности. + + Обязательная группа комнатности обнуляла ВСЕ ЧЕТЫРЕ поля, хотя площадь и этажность + в заголовке есть. rooms=0 — конвенция kit'а («0 = студия»), а не «неизвестно». + """ + sugg = _parse_suggestion( + {"id": 3, "title": "Квартира-студия, 34,2 м², 9/10 эт.", "price": 3_500_000} + ) + assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (0, 34.2, 9, 10) + + +def test_placement_history_gets_same_title_fix() -> None: + """Тот же регексп чинит второго писателя — house_placement_history (8.8% без площади).""" + from scraper_kit.providers.avito.imv import _parse_placement_item + + item = _parse_placement_item({"id": 9, "title": "Квартира-студия, 28 м², 2/17 эт."}) + assert (item.rooms, item.area_m2, item.floor, item.total_floors) == (0, 28.0, 2, 17) + + def test_live_fixture_suggestions_carry_image_link() -> None: """Живой capture avito_imv_getdata.json: у подсказок реально есть imageLink.""" data = json.loads((_FIXTURES / "avito_imv_getdata.json").read_text("utf-8")) @@ -165,15 +189,15 @@ def test_save_imv_result_binds_image_link_and_metrics() -> None: assert (bound["rooms"], bound["area"], bound["floor"], bound["total_floors"]) == (2, 42.0, 4, 5) -# ══ 2. Статус «снято» в дневной истории объявлений ════════════════════════════ +# ══ 2. «Снято» и «протухло» в дневной истории объявлений ══════════════════════ _DEACT_SQL_BUILDERS = (deact_mod._build_all_segments_sql, deact_mod._build_segments_sql) @pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS) @pytest.mark.parametrize("column", sorted(deact_mod._ALLOWED_STALENESS_COLUMNS)) -def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, column: str) -> None: - """Деактивация и снимок 'closed' — один statement, значит одна транзакция. +def test_deactivation_writes_stale_snapshot_in_same_statement(build: Any, column: str) -> None: + """Деактивация и снимок 'stale' — один statement, значит одна транзакция. До #2674 задача только двигала флаг: в listings_snapshots не появлялось ничего, и дата снятия объявления (лучший сигнал «скорее всего продано») восстанавливалась @@ -183,7 +207,7 @@ def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, colum assert "SET is_active = false" in sql assert "RETURNING id, price_rub" in sql assert "INSERT INTO listings_snapshots" in sql - assert "'closed'" in sql + assert "'stale'" in sql # Снимок пишется по строкам, которые вернул сам UPDATE, — не отдельной выборкой. assert "FROM stale" in sql # Идемпотентность: повторный прогон в те же сутки не падает на PK. @@ -192,6 +216,17 @@ def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, colum assert not re.search(r":\w+::", sql) +@pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS) +def test_ttl_path_never_claims_closed(build: Any) -> None: + """TTL-путь НЕ имеет права писать 'closed' — он не знает, что объявление снято. + + Замер: прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток + против 12 суток простоя обхода). Под статусом 'closed' это 6131 фальшивая «дата + продажи» одной датой. 'closed' остаётся только за 404 — там ответила площадка. + """ + assert "'closed'" not in str(build("last_seen_at").text) + + class _FakeResult: def __init__(self, rowcount: int) -> None: self.rowcount = rowcount @@ -282,18 +317,43 @@ def _schema_event_types() -> set[str]: return set(re.findall(r"'([a-z_]+)'", check)) -def test_event_writer_covers_every_schema_event_type() -> None: - """Писатель обязан уметь КАЖДЫЙ тип, который разрешает CHECK схемы. +# Два типа схемы НЕ ВЫВОДИМЫ из наших данных и намеренно не пишутся (#2674). +# is_active в снимке значит «мы видели», а не «есть на площадке», поэтому переход +# рождается тем, что скрейпер снова дошёл до источника. Контрольная группа за 14-18.07: +# domklik при покрытии 99.9-100% дал возвратов РОВНО 0 и снятий 1-4 в сутки, yandex при +# 34-43% — снятий 343-433 в сутки. Тот же обход, тот же день, разница только в покрытии. +# Отсюда: avito 13.07 (остановка обхода) 3023 «снятия» за сутки против контрольных 1-4 +# (точность ≈4%), и 4705 «возвратов» из 5493 за 12 дней — два дня после возобновления. +_NOT_DERIVABLE_EVENT_TYPES = {"delisted", "relisted"} - До #2674 из пяти типов писался один (price_change, 8288 строк), остальные - четыре — ноль за всё время. Тест сверяет со схемой, а не с копией списка, + +def test_event_writer_covers_every_derivable_schema_event_type() -> None: + """Писатель обязан уметь каждый ВЫВОДИМЫЙ тип из CHECK схемы. + + До #2674 из пяти типов писался один (price_change, 8288 строк). Дописаны два + выводимых; два оставшихся — сознательное решение, а не забытая ветка (см. + _NOT_DERIVABLE_EVENT_TYPES). Тест сверяет со схемой, а не с копией списка, поэтому покраснеет и на шестом типе, добавленном в CHECK без писателя. """ declared = _schema_event_types() assert len(declared) == 5, f"схема 079 изменилась: {sorted(declared)}" sql = str(snap_mod._EVENT_DIFF_SQL.text) - missing = {t for t in declared if f"'{t}'" not in sql} - assert not missing, f"типы событий без писателя: {sorted(missing)}" + expected = declared - _NOT_DERIVABLE_EVENT_TYPES + missing = {t for t in expected if f"'{t}'" not in sql} + assert not missing, f"выводимые типы без писателя: {sorted(missing)}" + + +def test_not_derivable_events_are_never_written() -> None: + """delisted/relisted не пишутся: при покрытии обхода 10-35% они неотличимы от + «скрейпер снова дошёл». Контроль — домклик со 100% покрытием: 0 возвратов за 5 суток. + + Гейт против «дописать для полноты»: журнал из догадок хуже пустого журнала. + """ + sql = str(snap_mod._EVENT_DIFF_SQL.text) + written = {t for t in _NOT_DERIVABLE_EVENT_TYPES if f"'{t}'" in sql} + assert not written, f"невыводимые типы попали в писатель: {sorted(written)}" + # is_active больше не читается вовсе — иначе ветка вернётся незаметно. + assert "is_active" not in sql def test_first_seen_requires_left_join_and_derivations_use_snapshot_fields() -> None: @@ -305,11 +365,16 @@ def test_first_seen_requires_left_join_and_derivations_use_snapshot_fields() -> sql = str(snap_mod._EVENT_DIFF_SQL.text) assert "LEFT JOIN LATERAL" in sql assert "p.snapshot_date IS NULL" in sql # first_seen - assert "p.is_active AND NOT t.is_active" in sql # delisted - assert "NOT p.is_active AND t.is_active" in sql # relisted assert "t.payload_hash IS DISTINCT FROM p.payload_hash" in sql # edited # Снимок за сегодня обязан отдавать поля, из которых выводятся ветки. - assert "SELECT listing_source_id, price_rub, is_active, payload_hash" in sql + assert "SELECT listing_source_id, price_rub, payload_hash" in sql + + +def test_event_dedup_works_across_runs_not_only_within_one() -> None: + """change_time усечён до суток: UNIQUE(source, change_time, type) должен гасить + повторный прогон в те же сутки (2 августа их было два), а не только строки одного.""" + sql = str(snap_mod._EVENT_DIFF_SQL.text) + assert "date_trunc('day', now())" in sql def test_price_change_division_guarded_by_nullif() -> None: @@ -341,22 +406,26 @@ class _EventFakeDB: pass -def test_counters_report_all_five_types_including_zeros(monkeypatch: pytest.MonkeyPatch) -> None: - """Счётчики прогона показывают все пять типов; не сработавший честно равен 0. +def test_counters_report_every_written_type_including_zeros( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Счётчики прогона показывают все пишущиеся типы; не сработавший честно равен 0. Ровно этого счётчика не хватало, чтобы заметить четыре нуля из пяти за 66 дней. + Счётчиков НЕвыводимых типов быть не должно — иначе вечный 0 будет читаться как + «событий не было», а не как «мы это сознательно не пишем». """ monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None) monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None) - db = _EventFakeDB([("delisted_events", 10_501), ("first_seen_events", 600)]) + db = _EventFakeDB([("first_seen_events", 600), ("edited_events", 17)]) out = snap_mod.snapshot_listing_sources(db, run_id=1) # type: ignore[arg-type] - assert out["delisted_events"] == 10_501 assert out["first_seen_events"] == 600 - assert out["relisted_events"] == 0 - assert out["edited_events"] == 0 + assert out["edited_events"] == 17 assert out["price_change_events"] == 0 - # Ключ на каждый тип из схемы — иначе «ноль» неотличим от «типа нет в counters». - for event_type in _schema_event_types(): + # Ключ на каждый пишущийся тип — иначе «ноль» неотличим от «типа нет в counters». + for event_type in _schema_event_types() - _NOT_DERIVABLE_EVENT_TYPES: assert f"{event_type}_events" in out + for event_type in _NOT_DERIVABLE_EVENT_TYPES: + assert f"{event_type}_events" not in out diff --git a/tradein-mvp/backend/tests/test_listing_source_snapshot.py b/tradein-mvp/backend/tests/test_listing_source_snapshot.py index 016ac2ed..e063bfb6 100644 --- a/tradein-mvp/backend/tests/test_listing_source_snapshot.py +++ b/tradein-mvp/backend/tests/test_listing_source_snapshot.py @@ -256,8 +256,6 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: assert out == { "snapshotted": 18355, "price_change_events": 42, - "delisted_events": 0, - "relisted_events": 0, "edited_events": 0, "first_seen_events": 0, } diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py index 1dc80f2d..30e609f2 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py @@ -215,10 +215,18 @@ def compute_imv_cache_key( # Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт." +# Комнатность НЕОБЯЗАТЕЛЬНА (#2674): 1991 заголовок из 25 055 (7.9%) — «Квартира-студия, +# 34,2 м², 9/10 эт.». Площадь и этажность там есть, но обязательная группа комнатности +# роняла match целиком и обнуляла ВСЕ ЧЕТЫРЕ поля. Тот же потолок был виден на соседней +# таблице (8.8% строк house_placement_history без площади) — обоих писателей чинит один +# регексп. Опциональная группа жадная, поэтому «3-к. квартира…» по-прежнему даёт rooms=3. _TITLE_RE = re.compile( - r"^(?P\d+)-к[.\s].*?(?P[\d,]+)\s*м².*?(?P\d+)/(?P\d+)\s*эт", + r"^(?:(?P\d+)-к[.\s])?.*?(?P[\d,]+)\s*м².*?(?P\d+)/(?P\d+)\s*эт", re.IGNORECASE, ) +# Студия = 0 комнат — конвенция kit'а (scraper_kit.base.RawLot.rooms «0 = студия», +# providers/yandex/detail.py). Отличаем её от «комнатность неизвестна» (None). +_TITLE_STUDIO_RE = re.compile(r"студи[яюей]", re.IGNORECASE) def _parse_title(title: str | None) -> dict[str, int | float | None]: @@ -235,7 +243,11 @@ def _parse_title(title: str | None) -> dict[str, int | float | None]: if not m: return result try: - result["rooms"] = int(m.group("rooms")) + rooms = m.group("rooms") + if rooms: + result["rooms"] = int(rooms) + elif _TITLE_STUDIO_RE.search(title): + result["rooms"] = 0 # студия result["area_m2"] = float(m.group("area").replace(",", ".")) result["floor"] = int(m.group("floor")) result["total_floors"] = int(m.group("total")) From 77336d351c67be02bbe24405171c0977a4ab32e2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 03:07:09 +0500 Subject: [PATCH 020/130] =?UTF-8?q?chore(tradein):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=83=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20212?= =?UTF-8?q?=20->=20213=20(=D0=BA=D0=BE=D0=BB=D0=BB=D0=B8=D0=B7=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20#2681)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2681 смержен, пока ветка была в работе, и принёс 212_sber_index_pull_weekly.sql. Номер 212 занят — беру 213 (свободен, проверено git ls-tree по origin/main после fetch). Почему локальный гейт молчал: test_new_files_do_not_reuse_prefix сравнивает префиксы файлов В ОДНОМ ДЕРЕВЕ, а смерженный 212_sber в этой ветке отсутствует. Проверено симуляцией (копия data/sql + stub 212_sber): с моим 212 тест КРАСНЫЙ («212 уже у нового 212_sber»), с 213 — зелёный. Кросс-ветковым реестром занятых номеров служит _manifest_applied.txt, но он отстал на 27 имён (171, 187-188, 189-211, 213), поэтому префикс 212 нигде не числился занятым. Про долг — отдельно, в этом PR манифест не трогаю. Apply after в шапке обновлён на 212_sber_index_pull_weekly.sql. --- ...atus_vocab.sql => 213_listings_snapshots_status_vocab.sql} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename tradein-mvp/backend/data/sql/{212_listings_snapshots_status_vocab.sql => 213_listings_snapshots_status_vocab.sql} (95%) diff --git a/tradein-mvp/backend/data/sql/212_listings_snapshots_status_vocab.sql b/tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql similarity index 95% rename from tradein-mvp/backend/data/sql/212_listings_snapshots_status_vocab.sql rename to tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql index fdd27346..510db1ba 100644 --- a/tradein-mvp/backend/data/sql/212_listings_snapshots_status_vocab.sql +++ b/tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql @@ -1,4 +1,4 @@ --- 212_listings_snapshots_status_vocab.sql +-- 213_listings_snapshots_status_vocab.sql -- #2674 — словарь listings_snapshots.status стал трёхзначным: 'stale' ≠ 'closed'. -- -- ПРОБЛЕМА: комментарий колонки (016) обещал два значения — 'active' / 'closed' — @@ -16,7 +16,7 @@ -- словарь на историческую таблицу — деструктивный риск ради нулевой выгоды. -- -- Idempotent: COMMENT ON COLUMN — безусловная перезапись, безопасно повторно. --- Apply after: 211_sales_vs_listings_segment_guard.sql +-- Apply after: 212_sber_index_pull_weekly.sql BEGIN; From 4d0795ae7a6ae9330a00d6bbb3833bd99da98848 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 03:34:18 +0500 Subject: [PATCH 021/130] =?UTF-8?q?fix(tradein/admin):=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B8,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BD=D0=B5=20=D0=BC=D0=BE=D0=B3=D1=83=D1=82=20?= =?UTF-8?q?=D0=B1=D1=8B=D1=82=D1=8C=20=D0=BD=D0=B5=D0=BD=D1=83=D0=BB=D0=B5?= =?UTF-8?q?=D0=B2=D1=8B=D0=BC=D0=B8,=20=D0=B8=20=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Четыре находки одного класса: админка показывает числа, которые никогда не бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как «всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего. 1. «Помечено выбросов» (v_data_quality.outliers_flagged) — УБРАН вместе с колонкой listings.is_outlier. Механизм не «не доделан»: «выброс» у эстиматора вычисляется Tukey-фильтром по КОНКРЕТНОЙ подборке аналогов и живёт один запрос — один и тот же лот выброс для одной оценки и нормальный аналог для соседней. Persist-флаг на объявлении такое отношение выразить не может, реализовать пометку нечем. 2. http_requests / http_errors / returning_count / disappeared_count — УБРАНЫ. HTTP-запросы не считает ни один фетчер (заполнить нечем без сквозной инструментации). Ошибки и «пропало/вернулось» уже считает тот, кто их знает, и кладёт в counters jsonb: errors_count у pipeline, deactivated/revived у deactivate_stale_*. Отдельные колонки были бы вторым определением того же. 3. run_type — УБРАН из API, из таблицы админки и из схемы. Ни одно место кода его не задавало; DEFAULT из 051 подписывал 'city_sweep' даже proxy_healthcheck. Колонка «Тип» в UI заменена на «Источник» — там осмысленное значение. 4. Фильтр источников — теперь из данных (GET /scrape/runs/sources, SELECT DISTINCT source). Захардкоженная тройка не просто была неполной: сравнение точное, а строк с source='avito'/'cian'/'yandex' в таблице нет вообще, то есть каждый пункт фильтра давал пустую выдачу, и пустой выбор («Все») тоже — он молча подставлял source вкладки. Новый источник появляется в списке сам. Числа с прода (tradein-postgres, 2026-08-06): is_outlier=true у 0 из 93 408 listings (NULL у 0 — только DEFAULT); четыре счётчика = 0 во всех 3244 прогонах с миграции 015; run_type — одно значение на 3244 строки; 53 реальных источника, 2466 прогонов (76%) вне трёх площадок, включая весь Домклик. Миграция 214 идемпотентна; v_data_quality пересоздан тем же DDL минус outliers_flagged (порядок DROP VIEW → DROP COLUMN → CREATE как в 095). --- tradein-mvp/backend/app/api/v1/admin.py | 32 ++- .../backend/app/services/scrape_runs.py | 25 +- .../data/sql/214_drop_dead_run_metrics.sql | 109 +++++++++ .../tests/test_2674_dead_admin_metrics.py | 221 ++++++++++++++++++ .../backend/tests/test_scraper_admin_apis.py | 9 +- .../src/components/scrapers/RunsTable.tsx | 84 ++++--- .../src/scraper_kit/orchestration/runs.py | 6 +- 7 files changed, 440 insertions(+), 46 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql create mode 100644 tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index f536f684..288e71b8 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2189,11 +2189,17 @@ async def scrape_house_imv_backfill( class UnifiedScrapeRunRow(BaseModel): - """Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).""" + """Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче). + + #2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом + 'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и + таблица подписывала им прогоны, которые никаким sweep не были — + proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало, + называет `source`. + """ run_id: int source: str - run_type: str | None = None status: str params: dict | None = None counters: dict | None = None @@ -2210,6 +2216,12 @@ class UnifiedScrapeRunsResponse(BaseModel): rows: list[UnifiedScrapeRunRow] +class ScrapeRunSourcesResponse(BaseModel): + """Список source'ов для фильтра истории прогонов — из данных, не из литерала.""" + + sources: list[str] + + class BrowserHealth(BaseModel): reachable: bool browsers: dict[str, bool] = Field(default_factory=dict) @@ -2294,7 +2306,6 @@ def list_scrape_runs_unified( UnifiedScrapeRunRow( run_id=r["run_id"], source=r["source"], - run_type=r.get("run_type"), status=r["status"], params=r.get("params"), counters=r.get("counters"), @@ -2310,6 +2321,21 @@ def list_scrape_runs_unified( ) +@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse) +def list_scrape_run_sources( + db: Annotated[Session, Depends(get_db)], +) -> ScrapeRunSourcesResponse: + """Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs. + + #2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в + таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением — каждый + пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том + числе) были недоступны для вопроса «что там происходит». Список берётся из + данных: новый source появляется в фильтре сам, без правки кода. + """ + return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db)) + + async def _probe_browser_health() -> BrowserHealth: """GET tradein-browser /health (timeout 5с). reachable=False при ошибке.""" url = f"{settings.browser_http_endpoint.rstrip('/')}/health" diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 39c91d5a..9090601d 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -201,7 +201,9 @@ def _alert_on_run_id( def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: """INSERT scrape_runs(source, status='running', params, started_at=NOW()). - run_type DEFAULT 'city_sweep' (из 051 миграции). + Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …); + отдельной колонки run_type больше нет — она 3244 прогона подряд молчала + дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674). Returns run_id (bigint). """ row = db.execute( @@ -445,7 +447,7 @@ def list_all( db.execute( text( f""" - SELECT id AS run_id, source, run_type, status, params, counters, + SELECT id AS run_id, source, status, params, counters, total_seen, new_count, started_at, finished_at, heartbeat_at, error AS error_text FROM scrape_runs @@ -460,3 +462,22 @@ def list_all( .all() ) return total, [dict(r) for r in rows] + + +def distinct_sources(db: Session) -> list[str]: + """Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту). + + #2674: фильтр источников в админке был захардкожен тремя площадками + (avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким + точным значением — все три пункта фильтра давали пустую выдачу, а 76% + прогонов (включая всю площадку Домклик) отфильтровать было нечем. + Список обязан приходить из данных: новый source появляется в фильтре сам, + без правки кода. + + Игнорирует фильтры /scrape/runs — иначе выбор источника вырезал бы из + выпадающего списка все остальные. + """ + rows = db.execute( + text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source") + ).fetchall() + return [str(r.source) for r in rows] diff --git a/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql new file mode 100644 index 00000000..bfb456f1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql @@ -0,0 +1,109 @@ +-- 214_drop_dead_run_metrics.sql +-- Purpose (#2674): удалить показатели, которые не могут быть ненулевыми. +-- +-- Один класс находок: колонка есть, админка её показывает, писателя нет ни одного — +-- и ноль читается как «всё чисто», а не как «мы это не считаем». Такой показатель +-- хуже отсутствующего: он создаёт ложную уверенность. Числа с прода 2026-08-06. +-- +-- 1. listings.is_outlier + v_data_quality.outliers_flagged +-- 93 408 строк listings, is_outlier = false у ВСЕХ, NULL у нуля — то есть +-- только DEFAULT из 002, ни одного UPDATE за всю историю. Писателя нет и не +-- задумывалось: «выброс» у эстиматора вычисляется Tukey-фильтром по КОНКРЕТНОЙ +-- подборке аналогов (estimator._filter_outliers) и живёт ровно один запрос — +-- один и тот же лот выброс для одной оценки и нормальный аналог для соседней. +-- Persist-флаг на объявлении такое отношение выразить не может в принципе, +-- поэтому реализовать «пометку» нечем: это остаток отменённой затеи +-- («помечено модели после aggregation», 002_core_tables.sql), а не недоделка. +-- +-- 2. scrape_runs.run_type +-- 3244 прогона, ровно одно значение 'city_sweep' — DEFAULT из 051; ни одно +-- место кода run_type не задаёт. Колонка подписывала «city_sweep» прогоны, +-- которые никаким sweep не были: proxy_healthcheck (1630 шт.), +-- deactivate_stale_*, sber_index_pull, rosreestr_dkp_import. Что именно бежало, +-- честно называет source — второй, вырожденной оси не нужно. +-- +-- 3. scrape_runs.http_requests / http_errors / returning_count / disappeared_count +-- Ноль во всех 3244 прогонах с миграции 015, писателя нет вообще. +-- HTTP-запросы не считает никто ни в одном фетчере — заполнить нечем без +-- сквозной инструментации всех путей. Ошибки и «пропало/вернулось» УЖЕ +-- считает тот, кто их знает, и кладёт в counters jsonb: errors_count у +-- pipeline, deactivated/revived у deactivate_stale_*. Отдельные колонки были +-- бы вторым, иначе определённым счётчиком того же слова. +-- +-- ⚠️ View-зависимость: v_data_quality (последний DDL — 095_dead_schema.sql) читает +-- listings.is_outlier и содержит CTE `SELECT * FROM listings`, что фиксирует +-- column-level зависимость на все колонки. Порядок тот же, что в 095: +-- DROP VIEW → DROP COLUMN → CREATE VIEW (уже без outliers_flagged). +-- +-- Dependencies: 002_core_tables.sql, 015_scrape_runs.sql, 051_scrape_runs_extend.sql, +-- 095_dead_schema.sql (последний DDL v_data_quality). +-- Идемпотентно: DROP VIEW IF EXISTS / DROP COLUMN IF EXISTS / CREATE OR REPLACE VIEW. + +BEGIN; + +-- ── 1. is_outlier: снять зависимый view, дропнуть колонку, пересоздать view ─── + +DROP VIEW IF EXISTS v_data_quality; + +ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS is_outlier; + +-- DDL идентичен 095, минус строка outliers_flagged (см. п.1 шапки). +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT * FROM listings WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago, + (SELECT count(*) FROM v_price_divergence) AS price_disagreements_count; + +COMMENT ON VIEW v_data_quality IS + 'KPI snapshot. Refreshed on-demand by /api/v1/admin/data-quality endpoint (Master Plan sec 8.1). ' + '#2674: outliers_flagged убран — is_outlier не писал никто, «выброс» определён только ' + 'внутри одной подборки аналогов (estimator._filter_outliers), не на объявлении.'; + +-- ── 2-3. scrape_runs: вырожденный run_type + четыре счётчика без писателя ───── + +ALTER TABLE IF EXISTS scrape_runs + DROP COLUMN IF EXISTS run_type, + DROP COLUMN IF EXISTS http_requests, + DROP COLUMN IF EXISTS http_errors, + DROP COLUMN IF EXISTS returning_count, + DROP COLUMN IF EXISTS disappeared_count; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py new file mode 100644 index 00000000..72178b9e --- /dev/null +++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py @@ -0,0 +1,221 @@ +"""#2674 — показатели админки, которые не могут быть ненулевыми. + +Один класс: колонка есть, поверхность её показывает, писателя нет ни одного — и +ноль читается оператором как «всё чисто», а не как «мы это не считаем». Такой +показатель хуже отсутствующего, поэтому «честно убрать» здесь — такой же +правильный исход, как «реализовать». + +Числа с прода (tradein-postgres, 2026-08-06, до фикса): + 1. listings.is_outlier — 93 408 строк, true у 0, NULL у 0: только DEFAULT из + миграции 002, ни одного UPDATE. v_data_quality.outliers_flagged рапортовал + этот ноль как «выбросов не помечено». + 2. scrape_runs.http_requests / http_errors / returning_count / + disappeared_count — ноль во всех 3244 прогонах с миграции 015 (74 дня у + самого старого), писателя нет вообще. + 3. scrape_runs.run_type — 3244 строки, ровно одно значение 'city_sweep' + (DEFAULT из 051); таблица админки подписывала им proxy_healthcheck (1630 + прогонов), deactivate_stale_*, sber_index_pull. + 4. Фильтр источников знал три значения ('avito'/'cian'/'yandex'), а + `source = :source` — точное сравнение: строк с ТАКИМ source в таблице 0. + То есть каждый пункт фильтра давал пустую выдачу, а пустой выбор («Все») + молча подставлял source вкладки — и тоже ноль. 53 реальных источника, + 2466 прогонов (76%) вне трёх площадок, включая весь Домклик. + +БД и сеть замоканы — реального Postgres не нужно. +""" + +from __future__ import annotations + +import inspect +import os +import re +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as runs_mod + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_SQL_DIR = _BACKEND_ROOT / "data" / "sql" +_RUNS_TABLE_TSX = ( + _BACKEND_ROOT.parent / "frontend" / "src" / "components" / "scrapers" / "RunsTable.tsx" +) + +# Колонки scrape_runs без единого писателя (см. шапку). Плюс listings.is_outlier. +_DEAD_RUN_COLUMNS = ( + "run_type", + "http_requests", + "http_errors", + "returning_count", + "disappeared_count", +) + + +# ── Фейковая БД: отдаёт ровно те source'ы, что ей задали ────────────────────── + + +class _FakeRow: + def __init__(self, source: str) -> None: + self.source = source + + +class _FakeResult: + def __init__(self, rows: list[_FakeRow]) -> None: + self._rows = rows + + def fetchall(self) -> list[_FakeRow]: + return self._rows + + +class _FakeDB: + """Минимальный Session-стаб: любой execute отдаёт заданный список source'ов.""" + + def __init__(self, sources: list[str]) -> None: + self.sources = sources + self.statements: list[str] = [] + + def execute(self, stmt: Any, params: Any = None) -> _FakeResult: + self.statements.append(str(stmt)) + return _FakeResult([_FakeRow(s) for s in self.sources]) + + +@pytest.fixture +def client_factory(): + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + def _make(db: Any) -> TestClient: + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + app.dependency_overrides[get_db] = lambda: db + return TestClient(app) + + return _make + + +# ══ 4. Фильтр источников берётся из данных ════════════════════════════════════ + + +def test_sources_endpoint_returns_whatever_the_table_holds(client_factory) -> None: + """Новый источник появляется в фильтре БЕЗ правки кода. + + 'domclick_city_sweep' — площадка, которой не было в захардкоженной тройке + (46 прогонов на проде нельзя было отфильтровать). 'newmarket_2031_sweep' — + заведомо несуществующий источник: если он доезжает до ответа, список + действительно приходит из данных, а не из литерала в коде. + """ + db = _FakeDB(["avito_city_sweep", "domclick_city_sweep", "newmarket_2031_sweep"]) + r = client_factory(db).get("/api/v1/admin/scrape/runs/sources") + + assert r.status_code == 200 + assert r.json()["sources"] == [ + "avito_city_sweep", + "domclick_city_sweep", + "newmarket_2031_sweep", + ] + + +def test_sources_query_ignores_run_filters() -> None: + """SELECT DISTINCT без WHERE по source/status — иначе выбор одного источника + вырезал бы из выпадающего списка все остальные.""" + sql = inspect.getsource(runs_mod.distinct_sources) + assert "DISTINCT source" in sql + assert "WHERE source = " not in sql + assert ":status" not in sql + + +def test_frontend_source_filter_has_no_hardcoded_list() -> None: + """RunsTable.tsx не содержит литерального списка площадок. + + Red на origin/main: там был `["", "avito", "cian", "yandex"] as const` — + три значения, которых в колонке source нет ни в одной из 3244 строк. + """ + tsx = _RUNS_TABLE_TSX.read_text(encoding="utf-8") + hardcoded = re.search(r'"avito"\s*,\s*"cian"\s*,\s*"yandex"', tsx) + assert hardcoded is None, ( + "список источников снова захардкожен в RunsTable.tsx — он обязан приходить " + "из /api/v1/admin/scrape/runs/sources, иначе следующая площадка опять " + "останется невидимой для оператора" + ) + assert "/scrape/runs/sources" in tsx + + +# ══ 3. run_type: вырожденная колонка ушла из API и из выдачи ══════════════════ + + +def test_unified_run_row_has_no_run_type(client_factory) -> None: + """Ответ /scrape/runs не несёт run_type — подписывать 'city_sweep' прогон + proxy_healthcheck честнее вообще нечем, кроме source.""" + from unittest.mock import patch + + row = { + "run_id": 5, + "source": "proxy_healthcheck", + "status": "done", + "params": None, + "counters": {"ok": 3}, + "total_seen": None, + "new_count": None, + "started_at": None, + "finished_at": None, + "heartbeat_at": None, + "error_text": None, + } + with patch("app.services.scrape_runs.list_all", return_value=(1, [row])): + r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs") + + assert r.status_code == 200 + body_row = r.json()["rows"][0] + assert "run_type" not in body_row + assert body_row["source"] == "proxy_healthcheck" + + +@pytest.mark.parametrize("list_all", [runs_mod.list_all, kit_runs.list_all]) +def test_list_all_selects_no_dropped_columns(list_all) -> None: + """Обе копии list_all (app.services и scraper_kit) перестали читать колонки, + которых после миграции 214 в таблице нет — иначе админка отдаёт 500.""" + sql = inspect.getsource(list_all) + still_there = [c for c in _DEAD_RUN_COLUMNS if re.search(rf"\b{c}\b", sql)] + assert not still_there, f"list_all всё ещё выбирает дропнутые колонки: {still_there}" + + +# ══ 1-3. Схема: колонок больше нет, и v_data_quality не рапортует выбросы ═════ + + +def _migration_214() -> str: + matches = sorted(_SQL_DIR.glob("214_*.sql")) + assert matches, "не найдена миграция 214_* (удаление мёртвых показателей #2674)" + return matches[0].read_text(encoding="utf-8") + + +def test_migration_drops_every_dead_column() -> None: + """Все пять колонок scrape_runs + listings.is_outlier дропаются идемпотентно.""" + sql = _migration_214() + missing = [c for c in _DEAD_RUN_COLUMNS if f"DROP COLUMN IF EXISTS {c}" not in sql] + assert not missing, f"миграция не дропает: {missing}" + assert "DROP COLUMN IF EXISTS is_outlier" in sql + + +def test_latest_v_data_quality_no_longer_reports_outliers() -> None: + """Действующее определение v_data_quality (последняя миграция, которая его + создаёт) не упоминает is_outlier. + + Red на origin/main: там последним был 095_dead_schema.sql со строкой + `(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged` + — показатель, который не мог быть ненулевым, потому что колонку не писал никто. + """ + marker = "CREATE OR REPLACE VIEW v_data_quality" + creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker in p.read_text(encoding="utf-8")) + assert creators, "не найдено ни одной миграции, создающей v_data_quality" + latest = creators[-1].read_text(encoding="utf-8") + body = latest.split("CREATE OR REPLACE VIEW v_data_quality")[-1].split(";")[0] + assert "outliers_flagged" not in body + assert "is_outlier" not in body diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index ab6d29f7..1a2db7e6 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -42,12 +42,15 @@ def client() -> TestClient: def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: - """list_all → total + rows; ISO-форматирование дат + error_text/run_type.""" + """list_all → total + rows; ISO-форматирование дат + error_text. + + run_type из выдачи убран (#2674): вырожденная колонка (одно значение во всех + 3244 прогонах), вид прогона называет source. + """ fake_rows = [ { "run_id": 5, "source": "avito_city_sweep", - "run_type": "city_sweep", "status": "done", "params": {"pages_per_anchor": 3}, "counters": {"lots_fetched": 120}, @@ -68,7 +71,7 @@ def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: row = body["rows"][0] assert row["run_id"] == 5 assert row["source"] == "avito_city_sweep" - assert row["run_type"] == "city_sweep" + assert "run_type" not in row assert row["total_seen"] == 120 assert row["new_count"] == 30 assert row["error_text"] is None diff --git a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx index 3544eb47..a94ef81e 100644 --- a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx @@ -15,7 +15,6 @@ import { export interface ScrapeRunFull { run_id: number; source: string; - run_type: string | null; status: string; params: Record | null; counters: Record | null; @@ -32,6 +31,10 @@ interface RunsListResp { rows: ScrapeRunFull[]; } +interface RunSourcesResp { + sources: string[]; +} + // ── Hook ─────────────────────────────────────────────────────────────────── // "skipped" (#2658) — пропущенное расписание (нет кук / уже бежит / нет handler'а); @@ -48,35 +51,32 @@ const RUN_STATUS_ALL = [ ] as const; type RunStatusFilter = (typeof RUN_STATUS_ALL)[number]; -// "" means "all sources"; otherwise a specific source prefix (avito / cian / yandex) -const RUN_SOURCE_FILTERS = ["", "avito", "cian", "yandex"] as const; -type RunSourceFilter = (typeof RUN_SOURCE_FILTERS)[number]; +// #2674: список источников приходит из данных, а не из литерала. Раньше здесь +// стояли три площадки (avito/cian/yandex), а в scrape_runs 53 разных source и НИ +// ОДНОЙ строки с таким точным значением — каждый пункт фильтра давал пустую +// выдачу, а 76% прогонов (вся площадка Домклик в том числе) отфильтровать было +// нечем. Новый источник теперь появляется в списке сам. +function useScrapeRunSources() { + return useQuery({ + queryKey: ["scrape-run-sources"], + // Отдельный ключ (не зависит от фильтров) — иначе список схлопывался бы при + // каждой смене фильтра, пока летит запрос. + queryFn: () => + apiFetch("/api/v1/admin/scrape/runs/sources"), + staleTime: 60_000, + retry: 1, + }); +} -const SOURCE_FILTER_LABELS: Record = { - "": "Все", - avito: "Avito", - cian: "Cian", - yandex: "Yandex", -}; - -function useScraperRuns( - source: ScraperSource, - status: RunStatusFilter, - sourceFilter: RunSourceFilter, - limit = 20, -) { +function useScraperRuns(status: RunStatusFilter, sourceFilter: string, limit = 20) { return useQuery({ - queryKey: ["scrape-runs", source, status, sourceFilter, limit], + queryKey: ["scrape-runs", status, sourceFilter, limit], queryFn: () => { const qs = new URLSearchParams({ limit: String(limit) }); - // When a specific sourceFilter is chosen, ignore the tab-level source - // and pass it verbatim as the ?source= param - if (sourceFilter) { - qs.set("source", sourceFilter); - } else { - // fallback: filter by the current tab provider - qs.set("source", source); - } + // Пусто = «Все», и это буквально все источники. Раньше пустой выбор молча + // подставлял source вкладки ("avito"), которого в данных не существует, — + // подпись «Все» показывала ноль прогонов (#2674). + if (sourceFilter) qs.set("source", sourceFilter); if (status) qs.set("status", status); return apiFetch( `/api/v1/admin/scrape/runs?${qs.toString()}`, @@ -186,16 +186,19 @@ interface RunsTableProps { export function RunsTable({ source }: RunsTableProps) { const [statusFilter, setStatusFilter] = useState(""); - const [sourceFilter, setSourceFilter] = useState(""); + const [sourceFilter, setSourceFilter] = useState(""); const qc = useQueryClient(); - const runsQ = useScraperRuns(source, statusFilter, sourceFilter); + const runsQ = useScraperRuns(statusFilter, sourceFilter); + const sourcesQ = useScrapeRunSources(); + // mark_cancelled(run_id) source-агностичен — отменяется тот прогон, что выбран + // в таблице, независимо от вкладки, с которой нажали. const cancelMut = useCancelCitySweep(source); function handleCancel(runId: number) { cancelMut.mutate(runId, { onSuccess: () => { void qc.invalidateQueries({ - queryKey: ["scrape-runs", source, statusFilter, sourceFilter], + queryKey: ["scrape-runs", statusFilter, sourceFilter], }); }, }); @@ -205,7 +208,7 @@ export function RunsTable({ source }: RunsTableProps) {

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

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

{/* Source filter */} @@ -219,7 +222,7 @@ export function RunsTable({ source }: RunsTableProps) { + {sourcesQ.isError && ( + + список источников не загрузился — фильтр показывает только «Все» + + )} {/* Status filter */} @@ -301,7 +310,10 @@ export function RunsTable({ source }: RunsTableProps) {
- + {/* #2674: было «Тип» = run_type, а он у всех 3244 прогонов один + ('city_sweep' по DEFAULT) и подписывал так даже + proxy_healthcheck. Что бежало — говорит source. */} + @@ -323,7 +335,7 @@ export function RunsTable({ source }: RunsTableProps) { color: "var(--fg-secondary, #5b6066)", }} > - {r.run_type ?? "sweep"} + {r.source} = () + m = re.search(rf"\b{col}\s*=\s*(now|clock_timestamp)\s*\(\s*\)", sql, re.I) + if m is not None: + self._stamp(col, m.group(1)) + ins = re.search( + r"INSERT INTO scrape_runs\s*\((.*?)\).*?VALUES\s*\((.*)\)", sql, re.S | re.I + ) + if ins is not None: # INSERT — отметки времени позиционные, в VALUES + for col, val in zip(_split_top(ins.group(1)), _split_top(ins.group(2)), strict=False): + f = re.fullmatch(r"(now|clock_timestamp)\s*\(\s*\)", val, re.I) + if col in _TS_COLS and f is not None: + self._stamp(col, f.group(1)) + return _FakeResult() + + def commit(self) -> None: + self.calls.append("commit") + self.tx_start = None + + def rollback(self) -> None: + self.calls.append("rollback") + self.tx_start = None + + +# ── 1. Финал прогона датируется концом работы, а не стартом транзакции ──────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_finished_at_covers_the_work_not_the_transaction_start(name: str) -> None: + """Прогон 346 дословно: 18 230 с работы в одной незакоммиченной транзакции. + + На старом коде finished_at = 0.032 (старт рабочей транзакции) → окно 32 мс при + пяти часах работы. Это и есть та строка, ради которой заведена задача. + """ + mod = _MODULES[name] + db = _FakePg() + db.row["started_at"] = 0.0 # create_run уже закоммитил claim + + db.wall = 0.020 + mod.update_heartbeat(db, 1, {"listings_processed": 0}) # heartbeat + commit + + db.wall = 0.032 + db.execute("SELECT id FROM listings WHERE history IS NULL") # рабочая транзакция + + db.wall = 18230.0 # пять часов работы, ни одного коммита + mod.mark_done(db, 1, {"listings_processed": 1200, "duration_sec": 18230}) + + assert db.row["finished_at"] == pytest.approx(18230.0) + assert db.row["heartbeat_at"] == pytest.approx(18230.0) + window = db.row["finished_at"] - db.row["started_at"] + assert window == pytest.approx(18230.0), "окно прогона обязано охватывать его работу" + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize("finalizer", ["mark_failed", "mark_banned"]) +def test_failed_and_banned_finals_are_wall_clock_too(name: str, finalizer: str) -> None: + """Тот же инвариант для неуспешных финалов. + + Их спасал defensive-rollback в начале (он закрывал рабочую транзакцию), но + полагаться на побочный эффект чужой защиты нельзя — проверяем явно. + """ + mod = _MODULES[name] + db = _FakePg() + db.wall = 0.019 + db.execute("SELECT 1") # рабочая транзакция открыта + db.wall = 1460.0 + getattr(mod, finalizer)(db, 1, "boom", {"checked": 0, "duration_sec": 1460}) + assert db.row["finished_at"] == pytest.approx(1460.0) + + +# ── 2. started_at переживает откат рабочей транзакции ──────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_started_at_survives_rolled_back_work_transaction(name: str) -> None: + """Требование #2702 п.1: отметка старта живёт в СВОЕЙ закоммиченной транзакции. + + create_run коммитит INSERT до возврата run_id, а ни один финализатор прогона + started_at не переписывает — поэтому откат рабочей транзакции его не достаёт. + Финал при этом обязан быть датирован концом работы (это и падает на старом коде). + """ + mod = _MODULES[name] + db = _FakePg() + + run_id = mod.create_run(db, source="cian_history_backfill", params={}) + assert run_id == 1 + assert db.calls == ["execute", "commit"], "INSERT прогона обязан коммититься сразу" + + db.wall = 0.030 + db.execute("UPDATE listings SET address = 'x'") # рабочая транзакция + db.wall = 100.0 + db.rollback() # работа упала и откатилась + + db.wall = 100.5 + db.execute("SELECT count(*) FROM listings") # новая рабочая транзакция + db.wall = 1460.0 + mod.mark_done(db, 1, {"checked": 0, "duration_sec": 1460}) + + assert db.row["started_at"] == pytest.approx(0.0), "started_at не должен сдвигаться" + assert db.row["finished_at"] == pytest.approx(1460.0) + + +# ── 3. Инвариант источника: никакая отметка времени не пишется now() ───────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_no_run_timestamp_is_written_with_now(name: str) -> None: + """`now()` в этих модулях не имеет корректного применения — его быть не должно. + + Проверяем весь исходник, а не отдельные запросы: INSERT в create_run пишет + started_at/heartbeat_at позиционно (в VALUES), и построчная проверка его бы + пропустила — ровно так дефект и дожил до 3 300 прогонов. + """ + src = inspect.getsource(_MODULES[name]) + # Регистрозависимо: SQL в этих модулях пишется в верхнем регистре, а строчное + # `now()` встречается в объяснительной прозе docstring'ов — ловим SQL, не текст. + assert re.search(r"\bNOW\s*\(\s*\)", src) is None + assert "clock_timestamp()" in src + + +def test_zombie_criterion_compares_real_clocks() -> None: + """#2702 п.2: поиск зависших сравнивает записанный heartbeat со «сейчас». + + Обе стороны сравнения обязаны быть настоящим временем: на проде у всех 6 + прогонов cian_history_backfill, помеченных 'zombie', записанный heartbeat так и + остался на отметке старта (max advance 0.0 с) — критерий решал по замороженной + отметке, хотя нормальный прогон этого источника длится до 5.06 ч. + """ + src = inspect.getsource(kit_scheduler.reap_zombies) + stmt = re.search(r"UPDATE scrape_runs.*?RETURNING id", src, re.S) + assert stmt is not None + assert re.search(r"\bNOW\s*\(\s*\)", stmt.group(0)) is None + assert stmt.group(0).count("clock_timestamp()") == 2 # finished_at + порог сравнения diff --git a/tradein-mvp/backend/tests/test_scrape_skip_visibility.py b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py index a16b5ae2..0683f683 100644 --- a/tradein-mvp/backend/tests/test_scrape_skip_visibility.py +++ b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py @@ -129,7 +129,9 @@ def test_mark_skipped_collapse_refreshes_detail_and_started_at() -> None: update = db.sql_of("UPDATE scrape_runs r") assert update is not None sql, params = update - assert "started_at = NOW()" in sql + # clock_timestamp(), а не NOW(): отметки времени прогона пишутся настоящими + # часами, иначе внутри долгой открытой транзакции они замерзают (#2702). + assert "started_at = clock_timestamp()" in sql assert "'detail', CAST(:details AS text)" in sql, "detail замерзает от первого пропуска" assert "first_skip_at" in sql, "начало стрика потеряно" assert params["details"] == "37 дн. назад" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index bbe367ca..ecb26a58 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -9,6 +9,15 @@ app-копии: 2. `mark_skipped` (#2658) есть только здесь: строки-пропуски создаёт исключительно планировщик (kit `_claim_run`/`scheduler_loop` + продуктовый cian pre_claim), app-копии эта функция не нужна. + +ВРЕМЯ ПИШЕТСЯ clock_timestamp(), А НЕ now() (#2702). `now()` в PostgreSQL — +синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции. Финализаторы +выполняются той же сессией, что и работа задачи, и если её транзакция оставалась +открытой всё время работы (нечего было коммитить), их UPDATE попадал ВНУТРЬ неё — +`finished_at` получал время начала работы. Прод 2026-08-06: 133 прогона из 487 с +окном finished_at − started_at меньше секунды при работе дольше 10 с; 126 из них в +диапазоне 9-64 мс (= от коммита claim'а до первого запроса рабочей транзакции). +Полный разбор — в docstring app-копии `app/services/scrape_runs.py`. """ from __future__ import annotations @@ -297,7 +306,10 @@ def _alert_on_run_id( def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: - """INSERT scrape_runs(source, status='running', params, started_at=NOW()). + """INSERT scrape_runs(source, status='running', params, started_at=clock_timestamp()). + + started_at пишется СВОЕЙ транзакцией (db.commit() ниже) — откат рабочей + транзакции задачи его уже не достаёт (#2702). Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …); отдельной колонки run_type больше нет — она 3244 прогона подряд молчала @@ -308,7 +320,9 @@ def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: text( """ INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at) - VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW()) + VALUES ( + :source, 'running', CAST(:params AS jsonb), clock_timestamp(), clock_timestamp() + ) RETURNING id """ ), @@ -357,9 +371,9 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = LIMIT 1 ) UPDATE scrape_runs r - SET heartbeat_at = NOW(), - started_at = NOW(), - finished_at = NOW(), + SET heartbeat_at = clock_timestamp(), + started_at = clock_timestamp(), + finished_at = clock_timestamp(), counters = COALESCE(r.counters, '{}'::jsonb) || jsonb_build_object( 'skips', COALESCE(CAST(r.counters ->> 'skips' AS int), 0) + 1, 'detail', CAST(:details AS text), @@ -385,7 +399,7 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = source, status, error, counters, started_at, heartbeat_at, finished_at ) VALUES ( - :source, 'skipped', :reason, CAST(:counters AS jsonb), NOW(), NOW(), NOW() + :source, 'skipped', :reason, CAST(:counters AS jsonb), clock_timestamp(), clock_timestamp(), clock_timestamp() ) RETURNING id """ @@ -409,7 +423,7 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None: - """UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки. + """UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки. total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926). @@ -420,7 +434,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None text( """ UPDATE scrape_runs - SET heartbeat_at = NOW(), + SET heartbeat_at = clock_timestamp(), counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -447,7 +461,7 @@ def is_cancelled(db: Session, run_id: int) -> bool: def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: - """Финализация run: status='done', finished_at=NOW(), counters + total_seen/new_count. + """Финализация run: status='done', finished_at + counters + total_seen/new_count. total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки — иначе admin/observability показывает 0 (audit #1926). @@ -457,7 +471,8 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: text( """ UPDATE scrape_runs - SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'done', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -496,7 +511,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) text( """ UPDATE scrape_runs - SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'failed', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), error = :error, counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -555,7 +571,8 @@ def mark_banned( text( """ UPDATE scrape_runs - SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'banned', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), error = :error, counters = CAST(:counters AS jsonb), ban_kind = :ban_kind, total_seen = COALESCE(CAST(:total_seen AS int), total_seen), @@ -586,7 +603,7 @@ def mark_cancelled(db: Session, run_id: int) -> bool: text( """ UPDATE scrape_runs - SET status = 'cancelled', finished_at = NOW() + SET status = 'cancelled', finished_at = clock_timestamp() WHERE id = :run_id AND status = 'running' RETURNING id """ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index e012353d..c670a298 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -235,16 +235,26 @@ def has_running_run(db: Session, source: str) -> bool: def reap_zombies(db: Session) -> int: - """Mark scrape_runs as 'zombie' если heartbeat не обновлялся > ZOMBIE_THRESHOLD_HOURS hours.""" + """Mark scrape_runs as 'zombie' если heartbeat не обновлялся > ZOMBIE_THRESHOLD_HOURS hours. + + clock_timestamp(), а не now() (#2702): критерий сравнивает ЗАПИСАННЫЙ heartbeat со + временем «сейчас», и обе стороны сравнения должны быть настоящим временем. `now()` + замерзает на старте транзакции — у писавшей heartbeat стороны это давало отставание + на весь возраст открытой рабочей транзакции (см. docstring runs.py), у читающей + стороны — на возраст тика. На проде это уже стоило ложных срабатываний: у всех 6 + прогонов cian_history_backfill, помеченных 'zombie', записанный heartbeat так и + остался на отметке старта (max advance 0.0 с) — при том что нормальный прогон этого + источника длится до 5.06 ч (прогон 346) и обязан был двигать heartbeat. + """ zombie_interval = f"{ZOMBIE_THRESHOLD_HOURS} hours" result = db.execute( text( """ UPDATE scrape_runs - SET status = 'zombie', finished_at = NOW() + SET status = 'zombie', finished_at = clock_timestamp() WHERE status = 'running' AND (heartbeat_at IS NULL - OR heartbeat_at < NOW() - CAST(:interval AS interval)) + OR heartbeat_at < clock_timestamp() - CAST(:interval AS interval)) RETURNING id """ ), From 01440928561ccbc4b4cdbdda7843678d571ba9b4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 09:55:54 +0000 Subject: [PATCH 044/130] =?UTF-8?q?fix(tradein/houses):=20camelCase-=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D1=8B=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D0=BE=D0=B4=D1=8F=D1=82=D1=81=D1=8F=20=D0=BA?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=D1=83=20=D1=83=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0=20(#2678)=20(#2719)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/sql/224_houses_house_type_canon.sql | 83 ++++++++++++ .../tests/test_2678_house_type_canon.py | 128 ++++++++++++++++++ .../src/scraper_kit/house_type_normalizer.py | 5 + .../src/scraper_kit/providers/avito/houses.py | 19 ++- 4 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql create mode 100644 tradein-mvp/backend/tests/test_2678_house_type_canon.py diff --git a/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql b/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql new file mode 100644 index 00000000..b8b58859 --- /dev/null +++ b/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql @@ -0,0 +1,83 @@ +-- 224_houses_house_type_canon.sql +-- Issue #2678 (хвост #2675/#2674): twin миграции 141 для таблицы ДОМОВ. +-- +-- Миграция 141 привела camelCase-вокабуляр Циана к канону только в listings. +-- В houses он остался — и каждый читатель типа дома чинил его у себя (#2675 +-- починил домовую оценку; поштучный путь и подбор аналогов продолжали сравнивать +-- 'monolithBrick' с 'monolith_brick' и не совпадать). +-- +-- ЗАМЕР ПРОДА 2026-08-06 (SELECT source, house_type, count(*) FROM houses GROUP BY 1,2): +-- канон: brick 388+5+4+1 · panel 325+3+2+1 · monolith 118+37+6+1 · +-- block 90+1+1 · monolith_brick 2+1 · wood 2 +-- camelCase: monolithBrick 48 (derived) + 8 (cian_newbuilding) = 56 · +-- gasSilicateBlock 1 · aerocreteBlock 1 +-- прочее: stalin 3 · other 18 · wireframe 1 +-- NULL: 8440 из 8880 строк (тип дома вообще неизвестен — не наш случай) +-- +-- ЖИВОГО ПИСАТЕЛЯ camelCase В houses НЕТ: у всех 80 неканоничных строк +-- last_scraped_at = 2026-05-24 14:04:20.012209 — одна и та же метка, т.е. +-- единственный прогон backfill'а 063 (промоут типов из listings ДО миграции 141). +-- Единственный живой писатель houses.house_type — avito-каталог домов +-- (providers/avito/houses.py), он пишет русские подписи через свою карту; в том +-- же PR он переведён на общий normalize_house_type, чтобы неизвестное значение +-- шло как NULL, а не как 'other' (его единственный источник неканона). +-- +-- ПРОВЕРКА СМЫСЛА ПЕРЕД СКЛЕЙКОЙ (требование #2674 — не слепить разное): +-- контрольная группа в своих же данных. Для каждой неканоничной строки взяты +-- типы её ЖЕ объявлений (listings.house_id_fk), уже нормализованных 141: +-- monolithBrick 56 домов — monolith_brick присутствует у ВСЕХ 56 → одно и то же +-- stalin 3 дома — brick (совпадает с решением 141: «сталинка» = кирпич) +-- aerocreteBlock 1 дом — block +-- gasSilicateBlock 1 дом — block +-- other 18 домов — разброс monolith/monolith_brick/brick/block, т.е. +-- 'other' = «неизвестно», а не отдельный материал +-- wireframe 1 дом — wireframe и у объявлений (само-согласовано) +-- Вывод: склейка безопасна ТОЛЬКО для четырёх camelCase-токенов + stalin. +-- +-- ЧТО НАМЕРЕННО НЕ ТРОГАЕМ: +-- 'other' (18) и 'wireframe' (1) — честного соответствия в каноне нет +-- (см. #2675: normalize_house_type схлопывает их в None на чтении, и это +-- правильный ответ — NULL нейтрален для soft-penalty эстиматора, а выдуманный +-- материал был бы враньём). Стирать их здесь тоже не будем: это единственный +-- след того, что источник что-то про дом сказал. +-- +-- BACKFILL (счётчики сняты на проде ДО применения, 2026-08-06): +-- monolithBrick -> monolith_brick : 56 строк +-- stalin -> brick : 3 строки +-- aerocreteBlock -> block : 1 строка +-- gasSilicateBlock -> block : 1 строка +-- foamConcreteBlock-> block : 0 строк (в houses не встречается, +-- оставлен для паритета с картой 141) +-- ИТОГО ожидаемо тронуто: 61 строка. +-- +-- Idempotent: WHERE перечисляет только мапимые токены → повторный прогон 0 строк. +-- Маппинг тождественен house_type_normalizer._RAW_TO_CANON и миграции 141 — +-- третьего словаря не заводим. + +BEGIN; + +UPDATE houses + SET house_type = CASE house_type + WHEN 'monolithBrick' THEN 'monolith_brick' + WHEN 'gasSilicateBlock' THEN 'block' + WHEN 'aerocreteBlock' THEN 'block' + WHEN 'foamConcreteBlock' THEN 'block' + WHEN 'stalin' THEN 'brick' + ELSE house_type + END + WHERE house_type IN ( + 'monolithBrick', 'gasSilicateBlock', 'aerocreteBlock', + 'foamConcreteBlock', 'stalin' + ); + +COMMENT ON COLUMN houses.house_type IS + 'Материал/тип дома, канон: panel/brick/monolith/monolith_brick/block/wood ' + '(тот же enum, что listings.house_type и scraper_kit.house_type_normalizer). ' + 'Писать сюда только через normalize_house_type — сырые вокабуляры источников ' + '(cian camelCase monolithBrick/gasSilicateBlock/stalin, yandex SCREAMING ' + 'MONOLIT_BRICK, русские подписи Авито) приводятся ДО записи, миграция 224 ' + 'вычистила исторические. Вне канона осталось намеренно: other (источник сказал ' + '«другое») и wireframe (каркас — материала в каноне нет). Неизвестный тип = ' + 'NULL, а не панель и не other: NULL нейтрален для soft-penalty эстиматора.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_2678_house_type_canon.py b/tradein-mvp/backend/tests/test_2678_house_type_canon.py new file mode 100644 index 00000000..c349a222 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2678_house_type_canon.py @@ -0,0 +1,128 @@ +"""#2678: тип дома приводится к канону У ИСТОЧНИКА, и словарь ровно один. + +Кейсы взяты не из головы, а из фактического замера прода 2026-08-06 +(`SELECT source, house_type, count(*) FROM houses GROUP BY 1,2`): + + monolithBrick 56 · other 18 · stalin 3 · aerocreteBlock 1 · + gasSilicateBlock 1 · wireframe 1 · плюс канон (brick/panel/monolith/ + monolith_brick/block/wood) и 8440 NULL. + +Проверяется три вещи: + 1. каждый фактический вариант → канон (или честный None); + 2. живой писатель houses.house_type (avito-каталог) больше не изобретает + 'other' и ходит через общий нормализатор; + 3. миграция 224 не заводит третий словарь — её CASE совпадает с картой кода. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from scraper_kit.house_type_normalizer import _RAW_TO_CANON, normalize_house_type +from scraper_kit.providers.avito.houses import _normalize_house_type as avito_house_type + +_MIGRATION_224 = ( + Path(__file__).resolve().parents[1] / "data" / "sql" / "224_houses_house_type_canon.sql" +) + +# Фактический словарь houses.house_type на проде 2026-08-06 → чем он обязан стать. +# None = «честно неизвестно» (NULL нейтрален для soft-penalty эстиматора, в отличие +# от выдуманного материала). +_PROD_VALUES: list[tuple[str, str | None]] = [ + ("monolithBrick", "monolith_brick"), # 56 строк + ("other", None), # 18 строк — источник сказал «другое», материала нет + ("stalin", "brick"), # 3 строки — «сталинка» = кирпич (решение миграции 141) + ("aerocreteBlock", "block"), # 1 строка + ("gasSilicateBlock", "block"), # 1 строка + ("wireframe", None), # 1 строка — каркас, в каноне такого материала нет + ("brick", "brick"), + ("panel", "panel"), + ("monolith", "monolith"), + ("monolith_brick", "monolith_brick"), + ("block", "block"), + ("wood", "wood"), +] + + +@pytest.mark.parametrize(("raw", "expected"), _PROD_VALUES) +def test_prod_value_maps_to_canon(raw: str, expected: str | None) -> None: + assert normalize_house_type(raw) == expected + + +def test_canon_survives_uppercase_including_monolith_brick() -> None: + """#2678 п.6: сквозной проброс канона был регистрозависим — кроме monolith_brick. + + Значений в верхнем регистре в базе сегодня ноль; это страховка на новый источник, + который отдаст канон «как в документации». + """ + assert normalize_house_type("MONOLITH_BRICK") == "monolith_brick" + assert normalize_house_type("Monolith_Brick") == "monolith_brick" + # Остальной канон и раньше переживал регистр — фиксируем, что не сломали. + for token in ("BRICK", "Panel", "MONOLITH", "Block", "WOOD"): + assert normalize_house_type(token) == token.lower() + + +# ── живой писатель houses.house_type: avito-каталог домов ──────────────────────── + + +@pytest.mark.parametrize( + ("label", "expected"), + [ + ("Монолитно-кирпичный", "monolith_brick"), + ("Панельный", "panel"), + ("КИРПИЧНЫЙ", "brick"), + (" Блочный ", "block"), + ("Деревянный", "wood"), + ], +) +def test_avito_house_label_maps_to_canon(label: str, expected: str) -> None: + assert avito_house_type(label) == expected + + +def test_avito_unknown_label_is_null_not_other() -> None: + """Незнакомая подпись → NULL. До #2678 здесь появлялось 'other'. + + 'other' всегда != канону, т.е. читатель получал не «неизвестно», а гарантированное + несовпадение: ложный штраф при подборе аналогов и пропуск оценки. + """ + assert avito_house_type("Саманный") is None + assert avito_house_type("") is None + assert avito_house_type(None) is None + + +def test_avito_writer_handles_foreign_vocabulary() -> None: + """Писатель ходит через общий нормализатор, а не только через свою карту.""" + assert avito_house_type("monolithBrick") == "monolith_brick" + assert avito_house_type("MONOLIT_BRICK") == "monolith_brick" + + +# ── миграция 224: тот же словарь, что в коде ───────────────────────────────────── + + +def _migration_case_pairs() -> dict[str, str]: + """WHEN 'x' THEN 'y' из исполняемой части миграции (без `--`-комментариев).""" + code = "\n".join( + line.split("--", 1)[0] for line in _MIGRATION_224.read_text(encoding="utf-8").splitlines() + ) + return dict(re.findall(r"WHEN\s+'([^']+)'\s+THEN\s+'([^']+)'", code)) + + +def test_migration_224_mapping_matches_code() -> None: + """Миграция не заводит третий словарь — каждая пара есть в _RAW_TO_CANON.""" + pairs = _migration_case_pairs() + assert pairs, "в миграции 224 не нашлось ни одного WHEN ... THEN" + for raw, canon in pairs.items(): + assert _RAW_TO_CANON.get(raw) == canon, f"{raw!r} расходится с house_type_normalizer" + + +def test_migration_224_touches_only_mapped_tokens() -> None: + """WHERE ограничен теми же токенами → 'other'/'wireframe'/канон не трогаются.""" + code = "\n".join( + line.split("--", 1)[0] for line in _MIGRATION_224.read_text(encoding="utf-8").splitlines() + ) + where_tokens = set(re.findall(r"'([A-Za-z]+)'", code.split("WHERE", 1)[1].split(";", 1)[0])) + assert where_tokens == set(_migration_case_pairs()) + assert "other" not in where_tokens + assert "wireframe" not in where_tokens diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/house_type_normalizer.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/house_type_normalizer.py index f9ad280d..aacb99ba 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/house_type_normalizer.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/house_type_normalizer.py @@ -47,6 +47,11 @@ _RAW_TO_CANON: dict[str, str] = { "panel": "panel", "block": "block", "wood": "wood", + # #2678 п.6: канон целиком, включая monolith_brick. Pass-through по _CANON + # регистрозависим, и без этого ключа 'MONOLITH_BRICK'/'Monolith_Brick' (форма, + # в которой канон может прийти от нового источника) уезжали бы в None — + # единственный канонический токен без такой страховки. + "monolith_brick": "monolith_brick", "monolithBrick": "monolith_brick", "gasSilicateBlock": "block", "aerocreteBlock": "block", diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py index a62f1755..c91b256b 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py @@ -39,6 +39,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError +from scraper_kit.house_type_normalizer import normalize_house_type from scraper_kit.providers.avito.serp import _is_firewall_page from scraper_kit.providers.avito.shared import RUS_MONTHS, _unix_to_date @@ -258,10 +259,24 @@ def _strip_price(price_str: str | None) -> int | None: def _normalize_house_type(raw: str | None) -> str | None: - """Нормализует тип дома: "Монолитный" → "monolith".""" + """Нормализует тип дома: "Монолитный" → "monolith". Незнакомое → None. + + Единственный живой писатель houses.house_type (см. save-функцию ниже), поэтому + канон обязан приводиться ЗДЕСЬ, а не у каждого читателя (#2678). + + Русская подпись Авито снимается локальной HOUSE_TYPE_MAP, результат прогоняется + через общий scraper_kit.house_type_normalizer: он знает и канон, и чужие + вокабуляры (cian camelCase, yandex SCREAMING) — на случай, если карточка дома + однажды придёт с чужим токеном. + + #2678: раньше незнакомое значение становилось 'other' — единственный источник + неканоничных значений в houses среди живых писателей. 'other' всегда != канон, + т.е. для soft-penalty эстиматора это ложный штраф, а NULL нейтрален (тот же + довод, что в docstring house_type_normalizer). + """ if not raw: return None - return HOUSE_TYPE_MAP.get(raw.lower(), "other") + return normalize_house_type(HOUSE_TYPE_MAP.get(raw.strip().lower(), raw)) def _normalize_house_class(raw: str | None) -> str | None: From b8afe13ae4b562b70cd16d31fc5ea8c6fd23e2ba Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 10:06:58 +0000 Subject: [PATCH 045/130] =?UTF-8?q?fix(tradein/scraper):=20=D0=B4=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=81=D0=B5=D1=80=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BD=D0=B5=D1=83=D0=B4=D0=B0=D1=87=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D0=B7=D0=B0=D0=BC=D0=BE?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=D1=82=D1=8C,=20=D0=BE=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=20=E2=80=94=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=D1=81=D1=8F=20=D1=83=D1=81=D0=BF=D0=B5=D1=85=D0=BE?= =?UTF-8?q?=D0=BC=20(#2670)=20(#2720)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrape_runs.py | 109 +++++-- .../test_2670_streak_and_partial_coverage.py | 295 ++++++++++++++++++ .../test_scraper_kit_pipeline_parity2.py | 5 + .../src/scraper_kit/orchestration/pipeline.py | 40 ++- .../src/scraper_kit/orchestration/runs.py | 107 +++++-- .../scraper_kit/providers/domclick/serp.py | 16 +- 6 files changed, 500 insertions(+), 72 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index a7ac714a..789be992 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -55,6 +55,52 @@ CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3 # невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned). CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3 +# #2670: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик прерывается +# не только в принципе, но и на практике. Оба сторожа ниже слали алерт ровно на N-й +# подряд неудаче и дальше молчали навсегда — а у постоянно сломанного источника +# «дальше» длится месяцами. Прод 2026-08-06: у avito_full_load 31 неудача подряд, +# последний успешный прогон 03.07 (34 дня без сбора), алерт был ровно один — на +# третьей; у avito_full_load_exhaustive 5 подряд. Тишина при этом неотличима от +# «всё хорошо» — ровно та ловушка, из-за которой #2574 месяц выглядела как норма. +# +# Вместо «ровно N» — разреженная лестница напоминаний: N, 2N, 4N, 8N…, а дальше не +# реже, чем раз в STREAK_ALERT_MAX_PERIOD×N прогонов. Лестница по ПРОГОНАМ, а не +# «раз в сутки», потому что источники идут разным тактом: domclick_city_sweep — раз +# в день, proxy_healthcheck — раз в полчаса; календарное разрежение для одного из +# них всегда будет либо спамом, либо молчанием. +STREAK_ALERT_MAX_PERIOD = 16 + +# Потолок сканирования истории источника при подсчёте стрика. Достигнутый потолок +# сам по себе повод для алерта (стрик заведомо огромен) — так «замолчать навсегда» +# невозможно по построению, а не по счастливому совпадению чисел. +STREAK_SCAN_LIMIT = 500 + + +def _streak_alert_due(streak: int, threshold: int) -> bool: + """Достиг ли стрик очередной вехи напоминания (#2670). + + True на threshold, 2×, 4×, 8×… и дальше на каждом кратном + STREAK_ALERT_MAX_PERIOD×threshold. Первый алерт приходит там же, где и раньше — + на N-й подряд неудаче; меняется только то, что он не последний. + """ + if streak < threshold or streak % threshold: + return False + mult = streak // threshold + if mult % STREAK_ALERT_MAX_PERIOD == 0: + return True + return mult & (mult - 1) == 0 + + +def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int: + """Длина серии подряд идущих «плохих» строк с начала списка (свежие — первыми).""" + streak = 0 + for row in rows: + if not is_bad(row): + break + streak += 1 + return streak + + # #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218) # РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый # статус» и «явное поле причины»: @@ -147,20 +193,27 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: def _alert_if_consecutive_failures(db: Session, source: str) -> None: - """Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD - завершённых запусков для данного source имеют статус 'failed' или 'banned'. + """Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд + (статусы 'failed'/'banned') у данного source. - Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос - возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является - failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога. + Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см. + _streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не + повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде + это дало avito_full_load — 31 неудача подряд, 34 дня без единого успешного + прогона, один алерт за всё время. + + Стрик прерывается любым завершением, кроме failed/banned, — по данным прода это + достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47 + завершённых прогонах), поэтому лестница не вырождается в постоянный алерт. Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный Sentry НЕ должен нарушать вызывающий mark_* путь. """ + if sentry_sdk is None: + return n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD try: - # Берём последние N+1 завершённых (non-running) запусков по source. - # Сортируем по finished_at DESC чтобы самые свежие шли первыми. + # Завершённые (non-running) прогоны источника, самые свежие первыми. rows = db.execute( text( """ @@ -171,31 +224,21 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None: LIMIT :limit """ ), - {"source": source, "limit": n + 1}, + {"source": source, "limit": STREAK_SCAN_LIMIT}, ).fetchall() - if len(rows) < n: - # Ещё не набралось N завершённых запусков вообще — алерт не нужен. + streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned")) + capped = streak >= STREAK_SCAN_LIMIT + if not capped and not _streak_alert_due(streak, n): return - # Первые N должны быть все failed/banned. - first_n = rows[:n] - if not all(r.status in ("failed", "banned") for r in first_n): - return - - # (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже - # должны были отправить алерт раньше и не стоит дублировать. - if len(rows) > n and rows[n].status in ("failed", "banned"): - return - - # Стрик ровно достиг порога — отправляем алерт. sentry_sdk.capture_message( - f"Scraper source '{source}' has {n} consecutive failed/banned runs — " + f"Scraper source '{source}' has {streak} consecutive failed/banned runs — " "manual intervention may be required (expired cookies / ban / broken parser).", level="error", ) logger.error( - "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n + "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, streak ) except Exception: pass # sentry_sdk not initialised in dev, or query failed — best-effort only @@ -210,8 +253,8 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: Причина обычно капча/пустая выдача источника, у которого нет (или не сработал) детект блокировки (см. providers/cian/serp.py, providers/yandex/serp.py). - Anti-spam: тот же N-й-стрик паттерн, что у _alert_if_consecutive_failures — - алерт срабатывает ровно когда стрик достигает порога, не на каждом запуске сверх. + Anti-spam: та же разреженная лестница вех, что у _alert_if_consecutive_failures + (#2670) — N, 2N, 4N…, а не «ровно N и дальше тишина». #2703: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик может прерваться. Сторож читал колонку total_seen (DEFAULT 0), которой у 28 из 53 @@ -241,10 +284,10 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: LIMIT :limit """ ), - {"source": source, "limit": n + 1}, + {"source": source, "limit": STREAK_SCAN_LIMIT}, ).fetchall() - if len(rows) < n: + if not rows: return def _is_zero_done(r: Any) -> bool: @@ -262,15 +305,13 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: _warn_source_has_no_result_metric(source, tuple(sorted(rows[0].counters or {}))) return - first_n = rows[:n] - if not all(_is_zero_done(r) for r in first_n): - return - - if len(rows) > n and _is_zero_done(rows[n]): + streak = _leading_streak(rows, _is_zero_done) + capped = streak >= STREAK_SCAN_LIMIT + if not capped and not _streak_alert_due(streak, n): return sentry_sdk.capture_message( - f"Scraper source '{source}' has {n} consecutive 'done' runs with zero " + f"Scraper source '{source}' has {streak} consecutive 'done' runs with zero " "lots fetched — captcha/layout-change likely undetected " "(manual check recommended).", level="error", @@ -278,7 +319,7 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: logger.error( "sentry alert sent: source=%s has %d consecutive zero-result 'done' runs", source, - n, + streak, ) except Exception: pass # sentry_sdk not initialised in dev, or query failed — best-effort only diff --git a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py new file mode 100644 index 00000000..ecc31dc9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py @@ -0,0 +1,295 @@ +"""#2670: длинная серия неудач замолкает навсегда; оборванный прогон зовётся успехом. + +**1. Анти-спам, запертый в «один раз навсегда».** Оба сторожа слали алерт РОВНО на N-й +подряд неудаче и дальше молчали. Пока источники падали вперемешку с успехами, серия +рвалась и алерт взводился заново; у постоянно сломанного источника рваться нечему. +Прод 2026-08-06: + + * `avito_full_load` — 31 неудача подряд (20 failed + 28 banned + 11 cancelled в + истории), последний успешный прогон 03.07, то есть 34 дня без сбора и ровно один + алерт — на третьей неудаче; + * `avito_full_load_exhaustive` — 5 подряд; + * `domclick_city_sweep` — текущий стрик 1 при 47 завершённых прогонах: условие + прерывания у этого сторожа ДОСТИЖИМО (в отличие от #2703, где оно было + недостижимо структурно) — просто у сломанного источника оно не наступает. + +Правка: разреженная лестница напоминаний N, 2N, 4N… и не реже, чем раз в +STREAK_ALERT_MAX_PERIOD×N прогонов. + +**2. Оборванный прогон.** Признак обрыва рождался в цикле `fetch_city` по ROOM_BUCKETS +(`break` на блоке, `continue` на битом бакете) и наружу не выходил: метод возвращает +голый список лотов, а pipeline писал в `counters.pages_fetched` расчётную оценку +`бакеты × страницы` — не измерение. Поэтому прогон, прошедший 3 бакета из 6, был +неотличим от полного и уходил в `done`. Теперь охват считает сам скрейпер и несёт его +наружу тем же каналом, что и `blocked`. + +Фальсификация: на старом коде падают тесты лестницы (сторож молчал при стрике >N) и +тест неполного охвата (прогон с лотами и неполным охватом уходил в `mark_done`). +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import runs as kit_runs +from scraper_kit.orchestration.pipeline import run_domclick_city_sweep +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} +PFX = "scraper_kit.orchestration.pipeline" + + +def _db(rows: list[Any]) -> MagicMock: + db = MagicMock() + db.execute.return_value.fetchall.return_value = rows + return db + + +# ── 1. Лестница напоминаний ────────────────────────────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize( + ("streak", "due"), + [ + (0, False), + (2, False), + (3, True), # первый алерт там же, где и раньше + (4, False), + (5, False), + (6, True), # 2N + (9, False), + (12, True), # 4N + (24, True), # 8N + (31, False), # прод-стрик avito_full_load — между вехами + (48, True), # 16N, дальше лестница линейная + (96, True), + (144, True), + (150, False), + ], +) +def test_streak_alert_ladder(name: str, streak: int, due: bool) -> None: + """N, 2N, 4N, 8N… и дальше каждые STREAK_ALERT_MAX_PERIOD×N — не «ровно N».""" + mod = _MODULES[name] + assert mod._streak_alert_due(streak, 3) is due + + +# ── 2. Сторож неудач: длинная серия не замолкает ───────────────────────────── + + +def _fail_rows(streak: int, tail: int = 3) -> list[SimpleNamespace]: + """Свежие сверху: `streak` неудач подряд, затем успешные прогоны.""" + return [SimpleNamespace(status="banned") for _ in range(streak)] + [ + SimpleNamespace(status="done") for _ in range(tail) + ] + + +def _run_failure_watchdog(mod: Any, rows: list[SimpleNamespace]) -> MagicMock: + sentry = MagicMock() + with patch.object(mod, "sentry_sdk", sentry): + mod._alert_if_consecutive_failures(_db(rows), "avito_full_load") + return sentry + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize("streak", [3, 6, 12, 24, 48]) +def test_failure_watchdog_keeps_reminding(name: str, streak: int) -> None: + """На старом коде алерт был только при streak == 3; остальные вехи молчали.""" + sentry = _run_failure_watchdog(_MODULES[name], _fail_rows(streak)) + sentry.capture_message.assert_called_once() + assert f"{streak} consecutive" in sentry.capture_message.call_args[0][0] + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize("streak", [0, 1, 2, 4, 31]) +def test_failure_watchdog_silent_between_milestones(name: str, streak: int) -> None: + """Анти-спам сохраняется: между вехами сторож молчит.""" + sentry = _run_failure_watchdog(_MODULES[name], _fail_rows(streak)) + sentry.capture_message.assert_not_called() + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_failure_streak_is_broken_by_success(name: str) -> None: + """Успешный прогон рвёт стрик — условие прерывания достижимо (прод: domclick, 1).""" + mod = _MODULES[name] + rows = [ + SimpleNamespace(status="banned"), + SimpleNamespace(status="banned"), + SimpleNamespace(status="done"), # рвёт: дальше 10 неудач уже не в счёт + *[SimpleNamespace(status="failed") for _ in range(10)], + ] + _run_failure_watchdog(mod, rows).capture_message.assert_not_called() + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_failure_watchdog_alerts_when_scan_window_is_full(name: str) -> None: + """Стрик длиннее окна сканирования — молчать нельзя, каким бы ни было число.""" + mod = _MODULES[name] + rows = [SimpleNamespace(status="failed") for _ in range(mod.STREAK_SCAN_LIMIT)] + _run_failure_watchdog(mod, rows).capture_message.assert_called_once() + + +# ── 3. Сторож нулей: та же лестница, семантика #2703 цела ──────────────────── + + +def _zero_rows(streak: int, tail: int = 3) -> list[SimpleNamespace]: + return [SimpleNamespace(status="done", counters={"lots_fetched": 0}) for _ in range(streak)] + [ + SimpleNamespace(status="done", counters={"lots_fetched": 42}) for _ in range(tail) + ] + + +def _run_zero_watchdog(mod: Any, rows: list[SimpleNamespace]) -> MagicMock: + sentry = MagicMock() + with patch.object(mod, "sentry_sdk", sentry): + mod._alert_if_consecutive_zero_results(_db(rows), "cian_full_load") + return sentry + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize(("streak", "called"), [(2, False), (3, True), (6, True), (7, False)]) +def test_zero_watchdog_uses_the_same_ladder(name: str, streak: int, called: bool) -> None: + sentry = _run_zero_watchdog(_MODULES[name], _zero_rows(streak)) + assert sentry.capture_message.called is called + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_watchdog_unmeasured_still_breaks_the_streak(name: str) -> None: + """#2703 не отменяется: «не измерено» рвёт стрик, а не копит его.""" + mod = _MODULES[name] + rows = [ + SimpleNamespace(status="done", counters={"lots_fetched": 0}), + SimpleNamespace(status="done", counters={"attempted": 5}), # метрики нет + *[SimpleNamespace(status="done", counters={"lots_fetched": 0}) for _ in range(10)], + ] + _run_zero_watchdog(mod, rows).capture_message.assert_not_called() + + +# ── 4. Охват прогона доезжает из скрейпера ─────────────────────────────────── + + +class _FakeFetcher: + async def __aenter__(self) -> _FakeFetcher: + return self + + async def __aexit__(self, *args: object) -> None: + return None + + def report_ban(self, reason: str) -> None: + return None + + +@pytest.fixture +def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "scraper_kit.providers._base.build_browser_fetcher", + lambda config, source: _FakeFetcher(), + ) + + +async def test_full_sweep_reports_full_coverage(_no_browser: None) -> None: + scraper = DomClickScraper(SimpleNamespace(browser_http_endpoint="http://x:9000")) + with patch.object(DomClickScraper, "_sweep_bucket", AsyncMock(return_value=None)): + await scraper.fetch_city(city_id=4) + assert scraper.buckets_completed == scraper.buckets_total == len(ROOM_BUCKETS) + + +async def test_broken_bucket_is_not_counted_as_covered(_no_browser: None) -> None: + """Бакет, упавший на разборе, пропускается (continue) — это и есть обрыв охвата.""" + scraper = DomClickScraper(SimpleNamespace(browser_http_endpoint="http://x:9000")) + calls = {"n": 0} + + async def _sweep(self: DomClickScraper, **_: object) -> None: + calls["n"] += 1 + if calls["n"] in (2, 5): + raise ValueError("bad BFF shape") + + with patch.object(DomClickScraper, "_sweep_bucket", _sweep): + await scraper.fetch_city(city_id=4) + assert scraper.buckets_completed == len(ROOM_BUCKETS) - 2 + assert scraper.fetch_errors == 2 + + +# ── 5. Неполный охват перестаёт быть «успехом» ─────────────────────────────── + + +class _RunsRecorder: + def __init__(self) -> None: + self.calls: list[str] = [] + + def is_cancelled(self, db: Any, run_id: int) -> bool: + return False + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.calls.append("update_heartbeat") + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.calls.append("mark_done") + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.calls.append("mark_failed") + + def mark_banned( + self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any + ) -> None: + self.calls.append("mark_banned") + + +async def _drive(*, lots_n: int, done: int, total: int, blocked: bool = False) -> list[str]: + recorder = _RunsRecorder() + lots = [MagicMock() for _ in range(lots_n)] + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_city = AsyncMock(return_value=lots) + scraper.blocked = blocked + scraper.geo_filtered = 0 + scraper.fetch_errors = total - done + scraper.buckets_completed = done + scraper.buckets_total = total + with ( + patch(f"{PFX}.DomClickScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(lots_n, 0))), + patch(f"{PFX}.runs", recorder), + ): + await run_domclick_city_sweep( + MagicMock(), + config=SimpleNamespace(browser_http_endpoint="http://x:9000"), + matcher=MagicMock(), + run_id=1, + city_id=4, + pages=1, + request_delay_sec=0.0, + ) + return recorder.calls + + +async def test_partial_coverage_with_lots_is_not_done() -> None: + """Прогон, прошедший 3 бакета из 6, не «успешен», даже если лоты есть. + + На старом коде эта ветка отсутствовала и прогон уходил в mark_done. + """ + assert (await _drive(lots_n=340, done=3, total=6))[-1] == "mark_failed" + + +async def test_full_coverage_with_lots_stays_done() -> None: + """Анти-оверрич: полный охват — по-прежнему done.""" + assert (await _drive(lots_n=340, done=6, total=6))[-1] == "mark_done" + + +async def test_block_still_wins_over_coverage() -> None: + """Блок проверяется раньше охвата: диагноз «нас прервали снаружи» точнее (#2657).""" + assert (await _drive(lots_n=39, done=2, total=6, blocked=True))[-1] == "mark_banned" + + +async def test_unknown_coverage_does_not_invent_a_verdict() -> None: + """Скрейпер не успел создаться (0/0) — судить об охвате нечем, ветка не срабатывает.""" + assert (await _drive(lots_n=12, done=0, total=0))[-1] == "mark_done" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index 37a1278b..0c1dc4c4 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -39,6 +39,7 @@ from scraper_kit.orchestration.pipeline import ( run_yandex_city_sweep, run_yandex_full_load, ) +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS PFX = "scraper_kit.orchestration.pipeline" @@ -413,6 +414,10 @@ async def _drive_domclick( blocked=blocked, geo_filtered=0, fetch_errors=fetch_errors, + # #2670: полный охват по умолчанию — эти фикстуры про блок/ошибки, не про обрыв + # (частичный охват проверяется в test_2670_streak_and_partial_coverage.py). + buckets_completed=len(ROOM_BUCKETS), + buckets_total=len(ROOM_BUCKETS), ) save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else []) if capture is not None: diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 6c35bc08..02fae3f5 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -3705,6 +3705,10 @@ class DomClickCitySweepCounters: errors_count: int = 0 blocked: int = 0 # 1 если QRATOR-блок был во время sweep geo_filtered: int = 0 # число офферов отфильтрованных geo-guard + # #2670: измеренный охват прогона (сколько комнатных бакетов пройдено из скольких). + # 0/0 = скрейпер не успел создаться — тогда судить об охвате нечем. + buckets_completed: int = 0 + buckets_total: int = 0 def to_dict(self) -> dict[str, int]: return {f.name: getattr(self, f.name) for f in fields(self)} @@ -3713,8 +3717,9 @@ class DomClickCitySweepCounters: # Дефолтные параметры sweep'а (EKB city_id=4). DOMCLICK_DEFAULT_CITY_ID: int = 4 DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] # vestigial; scraper sweeps all buckets -# Число BFF-бакетов (st/1/2/3/4/5+) — фиксировано; используется для watchdog. -_DOMCLICK_NUM_BUCKETS: int = 6 +# Число BFF-бакетов (st/1/2/3/4/5+) — используется для watchdog. Берётся из самого +# ROOM_BUCKETS: два литерала, обязанных совпадать, однажды уже разъезжались (#2674). +_DOMCLICK_NUM_BUCKETS: int = len(ROOM_BUCKETS) # Оценка времени одного fetch'а (network + parse) для watchdog. _DOMCLICK_PER_FETCH_S: float = 12.0 # Буфер сверху расчётного бюджета (cold browser start, save-фаза, price-splits). @@ -3836,6 +3841,10 @@ async def run_domclick_city_sweep( counters.geo_filtered = _s.geo_filtered # Не-block fetch-ошибки скрейпера учитываем в errors_count. counters.errors_count += _s.fetch_errors + # #2670: охват читаем даже когда фаза была снята по таймауту — ссылка на + # скрейпер живая, а его счётчик показывает, докуда прогон дошёл. + counters.buckets_completed = _s.buckets_completed + counters.buckets_total = _s.buckets_total # pages_fetched: worst-case число страниц (buckets × pages cap). counters.pages_fetched = _num_fetches @@ -3868,6 +3877,29 @@ async def run_domclick_city_sweep( "collected before abort (#2657)", counters.to_dict(), ) + elif 0 < counters.buckets_completed < counters.buckets_total: + # #2670: прогон оборван на середине — прошёл часть комнатных бакетов и + # бросил остальные (битый бакет → continue, снятие фазы по таймауту). + # Лоты у него есть, известной ошибки нет — и до этой ветки он отчитывался + # успехом. «Успех» определялся как «не поймали известную ошибку», а не как + # «сделали то, что собирались»: сравниваем с ожидаемым охватом, не с нулём. + logger.error( + "domclick-sweep run_id=%d: пройдено %d бакетов из %d " + "(lots=%d, errors=%d) — marking failed (#2670)", + run_id, + counters.buckets_completed, + counters.buckets_total, + counters.lots_fetched, + counters.errors_count, + ) + runs.mark_failed( + db, + run_id, + f"sweep оборван: пройдено {counters.buckets_completed} комнатных " + f"бакетов из {counters.buckets_total}, собрано " + f"{counters.lots_fetched} лотов (#2670)", + counters.to_dict(), + ) elif counters.lots_fetched == 0 and counters.errors_count > 0: logger.error( "domclick-sweep run_id=%d: 0 listings with errors=%d — marking failed", @@ -3885,11 +3917,13 @@ async def run_domclick_city_sweep( logger.info( "domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) " - "pages=%d errors=%d blocked=%d geo_filtered=%d", + "buckets=%d/%d pages=%d errors=%d blocked=%d geo_filtered=%d", run_id, counters.lots_fetched, counters.lots_inserted, counters.lots_updated, + counters.buckets_completed, + counters.buckets_total, counters.pages_fetched, counters.errors_count, counters.blocked, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index ecb26a58..d3fd9c01 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -50,6 +50,52 @@ CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3 # невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned). CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3 +# #2670: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик прерывается +# не только в принципе, но и на практике. Оба сторожа ниже слали алерт ровно на N-й +# подряд неудаче и дальше молчали навсегда — а у постоянно сломанного источника +# «дальше» длится месяцами. Прод 2026-08-06: у avito_full_load 31 неудача подряд, +# последний успешный прогон 03.07 (34 дня без сбора), алерт был ровно один — на +# третьей; у avito_full_load_exhaustive 5 подряд. Тишина при этом неотличима от +# «всё хорошо» — ровно та ловушка, из-за которой #2574 месяц выглядела как норма. +# +# Вместо «ровно N» — разреженная лестница напоминаний: N, 2N, 4N, 8N…, а дальше не +# реже, чем раз в STREAK_ALERT_MAX_PERIOD×N прогонов. Лестница по ПРОГОНАМ, а не +# «раз в сутки», потому что источники идут разным тактом: domclick_city_sweep — раз +# в день, proxy_healthcheck — раз в полчаса; календарное разрежение для одного из +# них всегда будет либо спамом, либо молчанием. +STREAK_ALERT_MAX_PERIOD = 16 + +# Потолок сканирования истории источника при подсчёте стрика. Достигнутый потолок +# сам по себе повод для алерта (стрик заведомо огромен) — так «замолчать навсегда» +# невозможно по построению, а не по счастливому совпадению чисел. +STREAK_SCAN_LIMIT = 500 + + +def _streak_alert_due(streak: int, threshold: int) -> bool: + """Достиг ли стрик очередной вехи напоминания (#2670). + + True на threshold, 2×, 4×, 8×… и дальше на каждом кратном + STREAK_ALERT_MAX_PERIOD×threshold. Первый алерт приходит там же, где и раньше — + на N-й подряд неудаче; меняется только то, что он не последний. + """ + if streak < threshold or streak % threshold: + return False + mult = streak // threshold + if mult % STREAK_ALERT_MAX_PERIOD == 0: + return True + return mult & (mult - 1) == 0 + + +def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int: + """Длина серии подряд идущих «плохих» строк с начала списка (свежие — первыми).""" + streak = 0 + for row in rows: + if not is_bad(row): + break + streak += 1 + return streak + + # #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218) # РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый # статус» и «явное поле причины»: @@ -142,12 +188,18 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: def _alert_if_consecutive_failures(db: Session, source: str) -> None: - """Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD - завершённых запусков для данного source имеют статус 'failed' или 'banned'. + """Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд + (статусы 'failed'/'banned') у данного source. - Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос - возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является - failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога. + Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см. + _streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не + повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде + это дало avito_full_load — 31 неудача подряд, 34 дня без единого успешного + прогона, один алерт за всё время. + + Стрик прерывается любым завершением, кроме failed/banned, — по данным прода это + достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47 + завершённых прогонах), поэтому лестница не вырождается в постоянный алерт. Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный Sentry НЕ должен нарушать вызывающий mark_* путь. @@ -156,8 +208,7 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None: return n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD try: - # Берём последние N+1 завершённых (non-running) запусков по source. - # Сортируем по finished_at DESC чтобы самые свежие шли первыми. + # Завершённые (non-running) прогоны источника, самые свежие первыми. rows = db.execute( text( """ @@ -168,31 +219,21 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None: LIMIT :limit """ ), - {"source": source, "limit": n + 1}, + {"source": source, "limit": STREAK_SCAN_LIMIT}, ).fetchall() - if len(rows) < n: - # Ещё не набралось N завершённых запусков вообще — алерт не нужен. + streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned")) + capped = streak >= STREAK_SCAN_LIMIT + if not capped and not _streak_alert_due(streak, n): return - # Первые N должны быть все failed/banned. - first_n = rows[:n] - if not all(r.status in ("failed", "banned") for r in first_n): - return - - # (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже - # должны были отправить алерт раньше и не стоит дублировать. - if len(rows) > n and rows[n].status in ("failed", "banned"): - return - - # Стрик ровно достиг порога — отправляем алерт. sentry_sdk.capture_message( - f"Scraper source '{source}' has {n} consecutive failed/banned runs — " + f"Scraper source '{source}' has {streak} consecutive failed/banned runs — " "manual intervention may be required (expired cookies / ban / broken parser).", level="error", ) logger.error( - "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n + "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, streak ) except Exception: pass # sentry_sdk not initialised in dev, or query failed — best-effort only @@ -207,8 +248,8 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: Причина обычно капча/пустая выдача источника, у которого нет (или не сработал) детект блокировки (см. providers/cian/serp.py, providers/yandex/serp.py). - Anti-spam: тот же N-й-стрик паттерн, что у _alert_if_consecutive_failures — - алерт срабатывает ровно когда стрик достигает порога, не на каждом запуске сверх. + Anti-spam: та же разреженная лестница вех, что у _alert_if_consecutive_failures + (#2670) — N, 2N, 4N…, а не «ровно N и дальше тишина». #2703: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик может прерваться. Сторож читал колонку total_seen (DEFAULT 0), которой у 28 из 53 @@ -240,10 +281,10 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: LIMIT :limit """ ), - {"source": source, "limit": n + 1}, + {"source": source, "limit": STREAK_SCAN_LIMIT}, ).fetchall() - if len(rows) < n: + if not rows: return def _is_zero_done(r: Any) -> bool: @@ -261,15 +302,13 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: _warn_source_has_no_result_metric(source, tuple(sorted(rows[0].counters or {}))) return - first_n = rows[:n] - if not all(_is_zero_done(r) for r in first_n): - return - - if len(rows) > n and _is_zero_done(rows[n]): + streak = _leading_streak(rows, _is_zero_done) + capped = streak >= STREAK_SCAN_LIMIT + if not capped and not _streak_alert_due(streak, n): return sentry_sdk.capture_message( - f"Scraper source '{source}' has {n} consecutive 'done' runs with zero " + f"Scraper source '{source}' has {streak} consecutive 'done' runs with zero " "lots fetched — captcha/layout-change likely undetected " "(manual check recommended).", level="error", @@ -277,7 +316,7 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: logger.error( "sentry alert sent: source=%s has %d consecutive zero-result 'done' runs", source, - n, + streak, ) except Exception: pass # sentry_sdk not initialised in dev, or query failed — best-effort only diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py index 5aba0195..1a41094f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py @@ -235,6 +235,9 @@ class DomClickScraper(BaseScraper): geo_filtered — офферы вне ЕКБ bbox или с неверным offerRegionName blocked — True если sweep был прерван QRATOR-блоком fetch_errors — не-block ошибки извлечения JSON (truncated/garbled/bad shape) + buckets_total — сколько комнатных бакетов прогон собирался пройти + buckets_completed — сколько прошёл ФАКТИЧЕСКИ (#2670); меньше total = прогон + оборван, сколько бы лотов он ни успел взять """ name = "domklik" @@ -263,6 +266,14 @@ class DomClickScraper(BaseScraper): # структура). В отличие от parse_failures (per-item), это per-fetch ошибки, # которые ограничивают сбор бакета. Учитываются в honest-status pipeline. self.fetch_errors: int = 0 + # #2670: охват прогона. `blocked` отвечает на «нас прервали снаружи», эти два — + # на «сколько работы прогон реально сделал». Признак обрыва рождается ЗДЕСЬ, в + # цикле по ROOM_BUCKETS (break на блоке, continue на битом бакете), и до #2670 + # наружу не выходил: fetch_city возвращает голый список лотов, а pipeline писал + # в counters.pages_fetched расчётную оценку buckets × pages — не измерение. + # Поэтому прогон, прошедший 3 бакета из 6, был неотличим от полного. + self.buckets_total: int = len(ROOM_BUCKETS) + self.buckets_completed: int = 0 async def __aenter__(self) -> DomClickScraper: await super().__aenter__() @@ -356,12 +367,15 @@ class DomClickScraper(BaseScraper): exc_info=True, ) continue + self.buckets_completed += 1 logger.info( - "domklik: fetch_city done city_id=%d total=%d " + "domklik: fetch_city done city_id=%d total=%d buckets=%d/%d " "parse_failures=%d geo_filtered=%d fetch_errors=%d blocked=%s", city_id, len(out_lots), + self.buckets_completed, + self.buckets_total, self.parse_failures, self.geo_filtered, self.fetch_errors, From a52b9d07cf4e317c3427fd9045f15cbcba9910d4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 10:18:14 +0000 Subject: [PATCH 046/130] =?UTF-8?q?fix(tradein/browser):=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=20evaluate=20=D0=BD=D0=B0=20=D1=82?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B6=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B5=20=E2=80=94=20=D0=B3=D0=BE=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BB=D1=83=D1=87=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=9F=D0=9E=D0=A1=D0=9B=D0=95=20load=20(#2676)=20(#2721)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/browser/server.py | 146 ++++++++-------- tradein-mvp/browser/test_server_fetch_json.py | 158 +++++++++++++----- 2 files changed, 188 insertions(+), 116 deletions(-) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index b2836430..ed630a90 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -1005,30 +1005,54 @@ async def _do_fetch_json( return await _fetch_json_once( provider, url, method=method, headers=headers, body=body, origin=origin ) - if _is_page_context_lost(exc): - # #2676: браузер жив, умерла ОДНА страница — relaunch не нужен (стоил бы - # ~10-20с и тёплые cookies инстанса). Повторяем на свежей странице, но с - # ожиданием `load`: без него повтор попадает в то же окно клиентской - # навигации, и «транзиентная» ошибка воспроизводится детерминированно. - logger.warning( - "tradein-browser[%s]: страница ушла в навигацию (%s), retry fetch-json " - "с ожиданием load: %s", - provider, - type(exc).__name__, - url, - ) - return await _fetch_json_once( - provider, - url, - method=method, - headers=headers, - body=body, - origin=origin, - wait_for_load=True, - ) + # #2676 обрабатывается ВНУТРИ _fetch_json_once (повтор evaluate на той же + # странице). Повторять здесь, на свежей странице, бесполезно: пересоздание + # воспроизводит ту же первую навигацию origin'а — проверено на проде 06.08, + # обе попытки упали одинаково. raise +# In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), #1917: +# первый XHR после навигации иногда падает до готовности стека. Повтор внутри страницы +# (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). HTTP-статусы (4xx/5xx) — +# это успешный resp, НЕ ретраим (решает caller). +_IN_PAGE_FETCH_JS = """async ({url, method, headers, body, retries, retryDelayMs}) => { + let lastErr; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const resp = await fetch(url, { + method: method || 'GET', + headers: headers || {}, + body: (body !== null && body !== undefined) ? body : undefined, + credentials: 'include', + }); + const text = await resp.text(); + return { status: resp.status, body: text, attempts: attempt + 1 }; + } catch (e) { + lastErr = e; + if (attempt < retries) { + await new Promise(r => setTimeout(r, retryDelayMs)); + } + } + } + throw lastErr; +}""" + + +async def _wait_for_load_best_effort(page: object, provider: str) -> None: + """Ждёт `load`, но не даёт ожиданию сорвать саму попытку (реклама/трекеры могут + держать страницу «загружающейся» бесконечно). Таймаут логируется, не пробрасывается. + """ + try: + await page.wait_for_load_state("load", timeout=FETCH_JSON_LOAD_WAIT_MS) # type: ignore[attr-defined] + except Exception as exc: + logger.info( + "tradein-browser[%s]: load не дождались (%s), пробуем evaluate как есть", + provider, + type(exc).__name__, + ) + + async def _fetch_json_once( provider: str, url: str, @@ -1037,7 +1061,6 @@ async def _fetch_json_once( headers: dict, body: object, origin: str, - wait_for_load: bool = False, ) -> dict: """Переходит на origin (same-origin якорь) и выполняет in-page fetch(url). @@ -1056,62 +1079,39 @@ async def _fetch_json_once( await _apply_resource_block(page) await _pace_provider(provider) await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined] - if wait_for_load: - # Только ретрай после #2676: даём клиентской навигации доиграть до `load`, - # иначе повтор попадает ровно в то же окно и падает так же. - try: - await page.wait_for_load_state( # type: ignore[attr-defined] - "load", timeout=FETCH_JSON_LOAD_WAIT_MS - ) - except Exception as exc: - # Best-effort: страница может дозагружаться бесконечно (реклама/трекеры). - # Не пробрасываем — evaluate ниже сам скажет, готова страница или нет; - # молчать нельзя, поэтому пишем в лог. - logger.info( - "tradein-browser[%s]: load не дождались (%s), пробуем evaluate как есть", - provider, - type(exc).__name__, - ) # БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies + # same-origin scope для fetch), а не отрендеренные listings. Settle-паузы # (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась # перед in-page fetch'ем. await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] - # In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), - # #1917: первый XHR после навигации иногда падает до готовности стека. Повтор - # внутри страницы (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). - # HTTP-статусы (4xx/5xx) — это успешный resp, НЕ ретраим (решает caller). - result: dict = await page.evaluate( # type: ignore[attr-defined] - """async ({url, method, headers, body, retries, retryDelayMs}) => { - let lastErr; - for (let attempt = 0; attempt <= retries; attempt++) { - try { - const resp = await fetch(url, { - method: method || 'GET', - headers: headers || {}, - body: (body !== null && body !== undefined) ? body : undefined, - credentials: 'include', - }); - const text = await resp.text(); - return { status: resp.status, body: text, attempts: attempt + 1 }; - } catch (e) { - lastErr = e; - if (attempt < retries) { - await new Promise(r => setTimeout(r, retryDelayMs)); - } - } - } - throw lastErr; - }""", - { - "url": url, - "method": method, - "headers": headers or {}, - "body": body, - "retries": FETCH_JSON_INPAGE_RETRIES, - "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, - }, - ) + payload = { + "url": url, + "method": method, + "headers": headers or {}, + "body": body, + "retries": FETCH_JSON_INPAGE_RETRIES, + "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, + } + try: + result: dict = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] + except Exception as exc: + if not _is_page_context_lost(exc): + raise + # #2676, живой прод 06.08: страница origin уходит в клиентскую навигацию + # ПОСЛЕ load, поэтому ни settle, ни ожидание load её не опережают, а повтор + # с новой страницей воспроизводит ту же первую навигацию (проверено на + # проде: две попытки подряд — та же ошибка). Навигация при этом ОДНА: + # к моменту исключения она уже случилась и у страницы НОВЫЙ контекст. + # Поэтому повторяем evaluate на ЭТОЙ же странице — стоит миллисекунды. + logger.warning( + "tradein-browser[%s]: контекст страницы пересоздан навигацией, " + "повтор evaluate на той же странице: %s", + provider, + url, + ) + await _wait_for_load_best_effort(page, provider) + await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] + result = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] finally: await page.close() # type: ignore[attr-defined] diff --git a/tradein-mvp/browser/test_server_fetch_json.py b/tradein-mvp/browser/test_server_fetch_json.py index 91466ad0..3e841cf4 100644 --- a/tradein-mvp/browser/test_server_fetch_json.py +++ b/tradein-mvp/browser/test_server_fetch_json.py @@ -374,17 +374,88 @@ def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch # ── гонка «execution context was destroyed» (#2676) ─────────────────────────────── -class _FakeSequenceBrowser: - """Отдаёт страницы по очереди: первая попытка ≠ вторая (retry на СВЕЖЕЙ странице).""" +def test_fetch_json_retries_evaluate_on_the_same_page(monkeypatch: pytest.MonkeyPatch) -> None: + """#2676 (живой прод 06.08): навигация УЖЕ случилась → повтор evaluate на той же странице. - def __init__(self, pages: list[_FakePage]) -> None: - self._pages = list(pages) - self.opened = 0 + Ключевое отличие от внешнего ретрая: страница НЕ пересоздаётся. Пересоздание + воспроизводит ту же первую навигацию origin'а (проверено на проде: обе попытки + падали одинаково), а после исключения у страницы уже новый, стабильный контекст. + """ + page = _FakePage({"status": 200, "body": "ok"}) + page.evaluate = AsyncMock( + side_effect=[ + RuntimeError("Page.evaluate: Execution context was destroyed, " + "most likely because of a navigation."), + {"status": 200, "body": '{"recovered": true}'}, + ] + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242) - async def new_page(self) -> _FakePage: - page = self._pages[min(self.opened, len(self._pages) - 1)] - self.opened += 1 - return page + result = asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + + assert result == {"status": 200, "body": '{"recovered": true}'} + assert page.evaluate.await_count == 2 + # Ни новой страницы, ни повторной навигации — только ожидание конца текущей. + assert browser.opened == 1 + assert page.goto_urls == ["https://www.avito.ru/"] + assert page.load_waits == [4242] + assert page.closed == 1 + + +def test_fetch_json_same_page_retry_happens_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Повтор evaluate ровно один: вторая та же ошибка уезжает наверх (там внешняя ветка).""" + message = "Execution context was destroyed" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError(message)) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + with pytest.raises(RuntimeError, match="Execution context was destroyed"): + asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + assert page.evaluate.await_count == 2 + assert page.closed == 1 + + +def test_fetch_json_no_same_page_retry_for_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Чужая ошибка evaluate не даёт второго прохода — повтор стоит денег.""" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError("boom")) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + with pytest.raises(RuntimeError, match="boom"): + asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + page.evaluate.assert_awaited_once() def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]: @@ -406,21 +477,21 @@ def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]: "Execution context was destroyed", ], ) -def test_do_fetch_json_retries_on_destroyed_context( +def test_do_fetch_json_recovers_from_destroyed_context( monkeypatch: pytest.MonkeyPatch, message: str ) -> None: - """#2676: страница ушла в навигацию → повтор на свежей странице, БЕЗ relaunch. + """#2676: обе формы сообщения playwright распознаются и переживаются повтором. До правки такая ошибка не попадала ни в одну ветку восстановления (_is_browser_crash матчит только закрытие цели/браузера/соединения) и уезжала наверх как 500. """ - racing_page = _FakePage({"status": 0, "body": ""}) - racing_page.evaluate = AsyncMock(side_effect=RuntimeError(message)) - settled_page = _FakePage({"status": 200, "body": '{"recovered": true}'}) - - server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page]) + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=[RuntimeError(message), {"status": 200, "body": '{"recovered": true}'}] + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) - monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242) relaunched = _no_relaunch(monkeypatch) result = asyncio.run( @@ -437,22 +508,21 @@ def test_do_fetch_json_retries_on_destroyed_context( assert result == {"status": 200, "body": '{"recovered": true}'} # Браузер живой — перезапускать его нельзя (тёплые cookies + ~10-20с). assert relaunched == [] - # Первая попытка НЕ ждала load (happy-path не удлиняется), повтор — ждал. - assert racing_page.load_waits == [] - assert settled_page.load_waits == [4242] - assert racing_page.closed == 1 and settled_page.closed == 1 + # И новой страницы тоже нет: повтор идёт по уже перешедшему контексту. + assert browser.opened == 1 + assert page.evaluate.await_count == 2 -def test_do_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) -> None: +def test_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) -> None: """Ожидание load на повторе — best-effort: таймаут не отменяет саму попытку.""" - racing_page = _FakePage({"status": 0, "body": ""}) - racing_page.evaluate = AsyncMock(side_effect=RuntimeError("Execution context was destroyed")) - settled_page = _FakePage({"status": 200, "body": "ok"}) - settled_page.wait_for_load_state = AsyncMock( # type: ignore[method-assign] + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=[RuntimeError("Execution context was destroyed"), {"status": 200, "body": "ok"}] + ) + page.wait_for_load_state = AsyncMock( # type: ignore[method-assign] side_effect=TimeoutError("Timeout 15000ms exceeded") ) - - server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page]) + server._browsers["avito"] = _FakeBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) _no_relaunch(monkeypatch) @@ -470,14 +540,18 @@ def test_do_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPat def test_do_fetch_json_gives_up_after_one_context_retry(monkeypatch: pytest.MonkeyPatch) -> None: - """Повтор ровно один: вторая та же ошибка уезжает наверх, а не крутит цикл.""" - message = "Page.evaluate: Execution context was destroyed" - first = _FakePage({"status": 0, "body": ""}) - first.evaluate = AsyncMock(side_effect=RuntimeError(message)) - second = _FakePage({"status": 0, "body": ""}) - second.evaluate = AsyncMock(side_effect=RuntimeError(message)) + """Повтор ровно один: вторая та же ошибка уезжает наверх, а не крутит цикл. - server._browsers["avito"] = _FakeSequenceBrowser([first, second]) + Свежей страницы тут намеренно НЕТ: пересоздание воспроизводит ту же первую + навигацию origin'а (прод 06.08 — обе попытки упали одинаково), а страховкой + остаётся один повтор всего запроса на стороне клиента (BrowserFetcher). + """ + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=RuntimeError("Page.evaluate: Execution context was destroyed") + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) _no_relaunch(monkeypatch) @@ -492,15 +566,15 @@ def test_do_fetch_json_gives_up_after_one_context_retry(monkeypatch: pytest.Monk origin="https://www.avito.ru/", ) ) - first.evaluate.assert_awaited_once() - second.evaluate.assert_awaited_once() + assert page.evaluate.await_count == 2 + assert browser.opened == 1 def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None: """Чужая ошибка НЕ ретраится — повтор невосстановимого жжёт бюджет прогона.""" page = _FakePage({"status": 0, "body": ""}) page.evaluate = AsyncMock(side_effect=RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")) - server._browsers["avito"] = _FakeSequenceBrowser([page]) + server._browsers["avito"] = _FakeBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) _no_relaunch(monkeypatch) @@ -521,11 +595,9 @@ def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.Monkey def test_fetch_json_handler_500_carries_reason_in_body(monkeypatch: pytest.MonkeyPatch) -> None: """Причина отказа остаётся в теле 500 — её читает _raise_for_sidecar_status (#2708).""" message = "Execution context was destroyed" - first = _FakePage({"status": 0, "body": ""}) - first.evaluate = AsyncMock(side_effect=RuntimeError(message)) - second = _FakePage({"status": 0, "body": ""}) - second.evaluate = AsyncMock(side_effect=RuntimeError(message)) - server._browsers["avito"] = _FakeSequenceBrowser([first, second]) + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError(message)) + server._browsers["avito"] = _FakeBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) _no_relaunch(monkeypatch) From 6cf9172d9652ed310850be4f8222b0ec0b8df34d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 10:42:39 +0000 Subject: [PATCH 047/130] =?UTF-8?q?fix(tradein/auth):=20=D0=B4=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BB=D0=BE=D0=BA=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D0=B1=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=81=D0=B2=D0=BE=D0=B8=D0=BC=20(#2714)=20(#?= =?UTF-8?q?2717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/auth.py | 13 +- tradein-mvp/backend/app/core/password.py | 89 +++++++++- tradein-mvp/backend/tests/conftest.py | 49 +++++- tradein-mvp/backend/tests/test_auth_api.py | 109 +++++++++++- tradein-mvp/backend/tests/test_password.py | 186 ++++++++++++++++++++- 5 files changed, 423 insertions(+), 23 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index 4309d5d7..bfe43cf6 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -35,6 +35,11 @@ Security: потолок (проверок/с не больше workers/282мс). Убрать одно без другого нельзя: вынос без потолка ускорил бы перебор вчетверо, потолок без выноса оставил бы отказ в обслуживании. Сверх очереди — 429, не ожидание. + Слоты делятся ПО АДРЕСУ (#2714): один источник не занимает больше половины, + иначе потолок бил и по своим — легитимный вход с верным паролем во время + флуда получал 429 столько раз, сколько пытался. Ключ — IP, поэтому защита + поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси, + общий адрес за NAT, ротация через ботнет) — см. docstring той же функции. - Поверх него — ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571): лимит по паре (username, IP) распределённый перебор обходит целиком, просто меняя адрес. Превышение порога не блокирует вход, а замедляет ответ @@ -260,7 +265,13 @@ async def login( # ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash # держит время ответа одинаковым независимо от существования аккаунта. try: - password_ok = await verify_password_bounded(body.password, hash_to_check) + # key=ip — доля слотов на адрес (#2714): один источник не занимает больше + # половины ёмкости, и вход остаётся открыт тем, кто приходит с других + # адресов. Ключ — ИМЕННО адрес, не имя: имя присылает клиент, и перебор + # менял бы его каждую попытку, получая полную долю на каждое. Границы + # применимости (IP подделывается за вторым прокси, разделяется за NAT, + # ротируется ботнетом) — в docstring `verify_password_bounded`. + password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip) except PasswordVerifyOverloadedError: # Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя — # ждущий держит соединение к БД. Отказ ОДИНАКОВ для любого имени и diff --git a/tradein-mvp/backend/app/core/password.py b/tradein-mvp/backend/app/core/password.py index a2dd3fbd..2cec3ada 100644 --- a/tradein-mvp/backend/app/core/password.py +++ b/tradein-mvp/backend/app/core/password.py @@ -96,8 +96,34 @@ _VERIFY_POOL = ThreadPoolExecutor( # потому одинаково честен под несколькими event loop'ами в тестах. _verify_inflight = 0 +# То же самое, но в разрезе ключа (#2714). Запись живёт РОВНО пока ключ держит +# хотя бы слот и удаляется на нуле: размер словаря ограничен числом слотов +# (`login_password_verify_max_inflight`), а не числом когда-либо виденных +# адресов — иначе перебор с ротацией IP растил бы его без границы. +_verify_inflight_by_key: dict[str, int] = {} -async def verify_password_bounded(plain: str, hashed: str) -> bool: + +def _per_key_slot_cap() -> int: + """Сколько слотов из общего лимита разрешено ОДНОМУ ключу. + + Половина — минимальное деление, при котором один источник, сколько бы он ни + слал, физически не может занять всё: вторая половина остаётся тем, кто + приходит впервые. Настройкой не сделано сознательно — это доля, а не + величина, и подкручивать её нечем: 100% возвращает поведение, ради отказа + от которого правка написана. + + Читается на каждом вызове, а не на импорте, — как `_throttle_delay_s`: + иначе тестовый monkeypatch лимита не влиял бы на долю. + + `max(1, …)`: при `max_inflight=1` половина округлилась бы в 0, и КАЖДЫЙ вход + получал бы отказ молча (свободных слотов нет ни у кого). Молчаливый отказ + всем — ровно тот класс поломки, от которого страхует `ge=1` на самой + настройке; здесь тот же страховочный пол, но от деления. + """ + return max(1, settings.login_password_verify_max_inflight // 2) + + +async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool: """`verify_password`, унесённая с событийного цикла И с сознательным потолком темпа (#2665). ДВЕ ПОЛОВИНЫ ОДНОЙ ПРАВКИ, И ЖИВУТ ОНИ ЗДЕСЬ ВМЕСТЕ НЕ ИЗ ЛЮБВИ К ПОРЯДКУ. @@ -129,24 +155,58 @@ async def verify_password_bounded(plain: str, hashed: str) -> bool: app/api/v1/auth.py; тогда потолок надо переносить в общее хранилище, предварительно убедившись, что оно реально доступно. + ДОЛЯ НА КЛЮЧ (#2714). Слоты — общий котёл, и потолок исправно бил по своим: + пока флуд держал все четыре, легитимный вход с ВЕРНЫМ паролем получал 429 + столько раз, сколько пытался. Поэтому *key* (у единственного вызывающего — + IP клиента) не берёт больше `_per_key_slot_cap()`: сколько бы один источник + ни слал, половина ёмкости остаётся тем, кто приходит впервые. Учёт по ключу + живёт ЗДЕСЬ ЖЕ и отдаётся тем же `_release_verify_slot` — инвариант «одна + точка выноса = одна точка учёта» не делится надвое. + + Чего это НЕ делает, и это не оговорка ради приличия. Ключом может быть + только IP, а IP: + - подделывается, если между нами и клиентом окажется ещё один прокси + (сейчас доверенный хоп ровно один — Caddy, `ratelimit._client_ip` берёт + правый элемент XFF; появится второй — ключ станет клиентским вводом); + - разделяется: за NAT/корпоративным шлюзом вся организация приходит с + одного адреса и делит одну долю с чужим перебором. СОСЕДЯМ ПО АДРЕСУ + СТАЛО ХУЖЕ, и это честный размен, а не побочный эффект: при флуде в + 3 запроса/с с того же адреса свои входят 69% попыток против 94% до + правки, а порог, за которым сосед перестаёт входить, падает с ~14 до + ~7 запросов/с. Взамен вход С ЧУЖИХ адресов идёт 100% против 37%; + размен принят сознательно — офис за одним NAT это единицы адресов, + а «все остальные» это все; + - меняется: ботнет или ротация прокси дают злоумышленнику столько ключей, + сколько ему нужно, и доля на ключ перестаёт быть ограничением. + То есть это ПОДНИМАЕТ СТОИМОСТЬ атаки (одного адреса больше не хватает, + чтобы закрыть вход всем), но не закрывает её. Закрывают принципиально + только доказательство работы на входе или второй фактор — отдельный разговор + и отдельная цена. + Raises: PasswordVerifyOverloadedError: очередь на проверку заполнена - (`login_password_verify_max_inflight`). Отказ мгновенный: ждать - нельзя, ждущий запрос держит соединение к БД. + (`login_password_verify_max_inflight`) ЛИБО *key* уже держит свою + долю (`_per_key_slot_cap`). Отказ мгновенный: ждать нельзя, ждущий + запрос держит соединение к БД. Оба случая неразличимы снаружи + намеренно — отказ приходит ДО сверки и потому ничего не сообщает о + том, существует ли учётка. """ global _verify_inflight if _verify_inflight >= settings.login_password_verify_max_inflight: raise PasswordVerifyOverloadedError + if _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap(): + raise PasswordVerifyOverloadedError loop = asyncio.get_running_loop() _verify_inflight += 1 + _verify_inflight_by_key[key] = _verify_inflight_by_key.get(key, 0) + 1 try: work = _VERIFY_POOL.submit(verify_password, plain, hashed) except BaseException: # Работа в пул НЕ встала — колбэка не будет, слот отдаём здесь. Иначе # утёкший слот навсегда отнимает у входа часть и без того малой ёмкости. - _verify_inflight -= 1 + _release_verify_slot(key) raise # Слот освобождает ЗАВЕРШЕНИЕ РАБОТЫ, а не выход из этой корутины. Отмена @@ -160,24 +220,35 @@ async def verify_password_bounded(plain: str, hashed: str) -> bool: # Именно поэтому колбэк висит на future ПУЛА, а не на обёртке из # `run_in_executor`: у обёртки «готово» наступает и при отмене — тест # `test_bounded_slot_freed_by_the_work_not_by_cancellation` ловит эту разницу. - work.add_done_callback(lambda _f: _schedule_verify_slot_release(loop)) + work.add_done_callback(lambda _f: _schedule_verify_slot_release(loop, key)) return await asyncio.wrap_future(work) -def _schedule_verify_slot_release(loop: asyncio.AbstractEventLoop) -> None: +def _schedule_verify_slot_release(loop: asyncio.AbstractEventLoop, key: str) -> None: """Возвращает слот по факту завершения работы в пуле (см. вызывающую). - Колбэк future пула исполняется В ПОТОКЕ ПУЛА, а счётчик — собственность + Колбэк future пула исполняется В ПОТОКЕ ПУЛА, а счётчики — собственность потока событийного цикла (на том и держится арифметика без лока), поэтому декремент переносим в цикл через `call_soon_threadsafe`. """ try: - loop.call_soon_threadsafe(_release_verify_slot) + loop.call_soon_threadsafe(_release_verify_slot, key) except RuntimeError: # Цикл уже закрыт (остановка процесса) — освобождать нечего и некому. logger.debug("verify slot release skipped: event loop is closed") -def _release_verify_slot() -> None: +def _release_verify_slot(key: str) -> None: + """Единственное место, где слот отдают: и общий счётчик, и счётчик ключа. + + Оба — одним движением и здесь же, а не по одному на каждом пути выхода: + разъедься они, и достаточно забыть одну строчку, чтобы ключ навсегда унёс + с собой долю ёмкости, которую никто уже не вернёт. + """ global _verify_inflight _verify_inflight -= 1 + left = _verify_inflight_by_key.get(key, 0) - 1 + if left > 0: + _verify_inflight_by_key[key] = left + else: + _verify_inflight_by_key.pop(key, None) diff --git a/tradein-mvp/backend/tests/conftest.py b/tradein-mvp/backend/tests/conftest.py index c6660a94..f9d02c57 100644 --- a/tradein-mvp/backend/tests/conftest.py +++ b/tradein-mvp/backend/tests/conftest.py @@ -1,13 +1,17 @@ """Repo-wide test config for tradein-mvp/backend. -Currently only registers custom pytest markers so they don't emit -PytestUnknownMarkWarning when used (`--strict-markers` is not enabled in -pyproject.toml, so an unregistered marker would only warn, not fail — this -just keeps output clean and documents intent in one place). +Регистрирует кастомные pytest-маркеры (иначе PytestUnknownMarkWarning: +`--strict-markers` в pyproject.toml не включён, так что незарегистрированный +маркер только предупреждал бы) и сторожит глобальное состояние, которое +переживает отдельный тест, — см. `_no_leaked_password_verify_slots`. """ from __future__ import annotations +import sys + +import pytest + def pytest_configure(config) -> None: config.addinivalue_line( @@ -16,3 +20,40 @@ def pytest_configure(config) -> None: "Pango/cairo/GObject libs, self-skips where unavailable (see " "tests/test_pdf_real_render.py docstring for how to run it for real).", ) + + +@pytest.fixture(autouse=True) +def _no_leaked_password_verify_slots(): + """Тест не оставляет за собой занятых слотов проверки пароля (#2665, #2714). + + Счётчики в `app.core.password` — состояние ПРОЦЕССА, а `pytest-asyncio` даёт + каждому тесту свой событийный цикл. Слот освобождает колбэк, посланный в + цикл через `call_soon_threadsafe`; если цикл к тому моменту закрыт, + `_schedule_verify_slot_release` ловит RuntimeError и слот не возвращается + никогда. На проде цикл живёт столько же, сколько процесс, и ветка + недостижима — а в тестах она копится молча и роняет НЕ ТОТ тест, который + её устроил: при пуле в 1 поток пары утечек хватает, чтобы всё дальнейшее + получало 429 «на ровном месте». + + Поэтому проверка тут и общая: считаем слоты после каждого теста. + + `sys.modules.get`, а не import: тестам, которые password.py не трогают + (большинство), незачем тянуть `Settings()` с его требованием DATABASE_URL. + """ + yield + + password_mod = sys.modules.get("app.core.password") + if password_mod is None: + return + + inflight = password_mod._verify_inflight + by_key = dict(password_mod._verify_inflight_by_key) + # Сброс ДО assert: иначе одна утечка красит все последующие тесты и виновник + # теряется среди пострадавших. + password_mod._verify_inflight = 0 + password_mod._verify_inflight_by_key.clear() + + assert inflight == 0 and not by_key, ( + f"тест оставил {inflight} занятых слотов проверки пароля (по ключам: {by_key}) — " + "утечка слота при пуле в 1 поток это вечный 429 всем на входе" + ) diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index eb2b664b..090b9422 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -847,10 +847,117 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive( # 3. Лишнее ОТКЛОНЯЕТСЯ, а не копится в очереди: очередь держала бы # соединения к БД и выбрала бы пул (QueuePool 5+10). assert codes.count(429) > codes.count(401), "избыток должен получать 429, а не ждать" - # 4. Вход не заблокирован совсем: потолок — это темп, а не «ноль попыток». + # 4. Потолок режет ТЕМП, а не обнуляет попытки: до bcrypt доезжает хоть + # что-то. Это утверждение ПРО АТАКУЮЩЕГО и ни слова не говорит о том, + # войдёт ли в это время живой человек — он не войдёт, пока слоты заняты + # флудом (#2714). Проверка «дверь открыта своим» — отдельным тестом ниже, + # и мерит она вход С ДРУГОГО КЛЮЧА, а не число попыток атакующего. assert len(attempts) >= 2 +# --------------------------------------------------------------------------- +# #2714 — потолок не должен бить по своим: доля слотов на один ключ +# --------------------------------------------------------------------------- + + +async def test_flood_from_one_ip_leaves_login_open_for_another_ip( + store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Пока один адрес непрерывно флудит, человек с ДРУГОГО адреса входит (#2714). + + Именно это ломал потолок #2665 в исходном виде: слоты — общий котёл, флуд + занимал все четыре, и легитимный вход с ВЕРНЫМ паролем получал 429 столько + раз, сколько пытался (замер в issue: 20 попыток → 20×429, 0×200). + + Тест мерит ровно заявленное — ВХОД С ДРУГОГО КЛЮЧА, а не латентность одного + запроса и не число попыток атакующего: с общим котлом «попытки атакующего + доезжают» и «свой войдёт» — разные утверждения, и первое зелено, когда + второе ложно. + + Число попыток пробы = `login_rate_limit`, и это не подгонка: столько входов + по паре (имя, IP) вообще разрешено за окно соседним `_LOGIN_LIMITER`. + Просить больше значило бы мерить ЕГО 429 вместо потолка сверок — то есть + получить красный тест на исправном коде. + + Стенд БЕЗ `RateLimitMiddleware` (его в тестовом приложении нет), поэтому + флуд здесь плотнее, чем один адрес может выдать на проде (там его режут + 300 запросов за 60с). Так и задумано: проверяем худший случай. + """ + verify_s = 0.05 + flood_ip = "203.0.113.66" + legit_ip = "198.51.100.10" + legit_password = "Secret123!" + + patch_identity_sessions(monkeypatch, lambda: _FakeDB(store)) + _capture_events(monkeypatch) + legit_hash = hash_password(legit_password) + store.add_user("realuser", legit_hash, role="employee") + app = _build_test_app(store) + + def _slow_verify(plain: str, hashed: str) -> bool: + """Двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше. + + Вердикт настоящий (а не всегда-False, как в тесте про темп выше) — без + него легитимный вход не дошёл бы до 200 и мерить было бы нечего. + """ + time.sleep(verify_s) + return hashed == legit_hash and plain == legit_password + + monkeypatch.setattr(password_mod, "verify_password", _slow_verify) + + flood_codes: list[int] = [] + flood_over = asyncio.Event() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://testserver" + ) as client: + + async def flooder(n: int) -> None: + i = 0 + while not flood_over.is_set(): + i += 1 + # Своё имя на каждую попытку — иначе флуд упрётся в + # `_LOGIN_LIMITER` (5 на пару имя+IP) и до потолка сверок не + # доедет вовсе: тест стал бы зелёным, ничего не проверив. + resp = await client.post( + "/api/v1/auth/login", + json={"username": f"nosuchuser{n}x{i}", "password": "guess"}, + headers={"x-forwarded-for": flood_ip}, + ) + flood_codes.append(resp.status_code) + + floods = [asyncio.create_task(flooder(n)) for n in range(8)] + try: + # Ждём ДОКАЗАННОГО насыщения: 429 у атакующего = слоты кончились. + # Без этого условия проба могла бы пройти по пустой очереди и тест + # был бы зелёным на сломанном коде. + saturation_deadline = time.monotonic() + 10.0 + while flood_codes.count(429) < 4: + assert time.monotonic() < saturation_deadline, ( + f"флуд не насытил слоты за 10с ({len(flood_codes)} ответов, " + f"429: {flood_codes.count(429)}) — мерить справедливость не на чем" + ) + await asyncio.sleep(0.01) + + probe_codes: list[int] = [] + for _ in range(config.settings.login_rate_limit): + resp = await client.post( + "/api/v1/auth/login", + json={"username": "realuser", "password": legit_password}, + headers={"x-forwarded-for": legit_ip}, + ) + probe_codes.append(resp.status_code) + finally: + flood_over.set() + await asyncio.gather(*floods) + + assert probe_codes.count(200) == len(probe_codes), ( + f"легитимный вход с {legit_ip} во время флуда с {flood_ip}: " + f"200={probe_codes.count(200)}, 429={probe_codes.count(429)}, " + f"401={probe_codes.count(401)} — потолок бьёт по своим (#2714)" + ) + + # --------------------------------------------------------------------------- # POST /logout # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_password.py b/tradein-mvp/backend/tests/test_password.py index 78df935c..a03e5c9f 100644 --- a/tradein-mvp/backend/tests/test_password.py +++ b/tradein-mvp/backend/tests/test_password.py @@ -6,6 +6,7 @@ import asyncio import os import threading import time +from concurrent.futures import ThreadPoolExecutor # С #2665 password.py читает настройки (размер пула проверок) — значит тянет # `Settings()`, которому нужен DATABASE_URL. В CI он в env (ci-tradein.yml), @@ -121,13 +122,27 @@ def test_verify_ceiling_defaults_stay_within_the_db_pool() -> None: "потолок перебора = workers/282мс. Подъём — осознанное решение " "«во сколько раз ускоряем перебор», а не рефакторинг: правь вместе с тестом" ) + # ЛИТЕРАЛЫ, а не арифметика от настройки. Доля на ключ (#2714) считается как + # max_inflight // 2, и сторож вида `cap == max_inflight // 2` был бы + # тавтологией: подъём max_inflight до 64 он бы проспал, а вместе с ним — + # возврат к «один адрес занимает всё» (доля 32 при очереди в 4 живых слота + # ничего не делит). Поэтому здесь зафиксированы ОБА числа. + assert settings.login_password_verify_max_inflight == 4, ( + "очередь 4 выбрана под QueuePool 5+10 и худшее ожидание 4/1×282мс ≈ 1.1с; " + "меняешь — пересчитывай и долю на ключ ниже" + ) + assert password_mod._per_key_slot_cap() == 2, ( + "один адрес держит не больше 2 слотов из 4: половина ёмкости обязана " + "оставаться тем, кто приходит впервые (#2714)" + ) async def test_bounded_gives_same_answer_as_sync() -> None: """Обёртка не меняет вердикт — она меняет только ГДЕ он считается.""" hashed = hash_password("correct horse battery staple") - assert await verify_password_bounded("correct horse battery staple", hashed) is True - assert await verify_password_bounded("wrong password", hashed) is False + key = "203.0.113.1" + assert await verify_password_bounded("correct horse battery staple", hashed, key=key) is True + assert await verify_password_bounded("wrong password", hashed, key=key) is False async def test_bounded_runs_off_the_event_loop_thread(monkeypatch: pytest.MonkeyPatch) -> None: @@ -145,7 +160,7 @@ async def test_bounded_runs_off_the_event_loop_thread(monkeypatch: pytest.Monkey return True monkeypatch.setattr(password_mod, "verify_password", _spy) - assert await verify_password_bounded("x", "y") is True + assert await verify_password_bounded("x", "y", key="203.0.113.1") is True assert seen and seen[0] != loop_thread @@ -165,8 +180,13 @@ async def test_bounded_rejects_surplus_instead_of_queueing(monkeypatch: pytest.M monkeypatch.setattr(password_mod, "verify_password", _slow) + # У каждого запроса СВОЙ ключ: тест про ОБЩИЙ потолок, и отказывать здесь + # обязан именно он. С одним ключом на всех первым сработал бы лимит доли + # (#2714) — числа сошлись бы по другой причине, а поломка общего потолка + # осталась бы незамеченной. results = await asyncio.gather( - *(verify_password_bounded("x", "y") for _ in range(6)), return_exceptions=True + *(verify_password_bounded("x", "y", key=f"203.0.113.{i}") for i in range(6)), + return_exceptions=True, ) rejected = [r for r in results if isinstance(r, PasswordVerifyOverloadedError)] admitted = [r for r in results if r is False] @@ -174,7 +194,7 @@ async def test_bounded_rejects_surplus_instead_of_queueing(monkeypatch: pytest.M assert len(rejected) == 4, results # Слоты возвращаются: после отработки очереди вход снова доступен. - assert await verify_password_bounded("x", "y") is False + assert await verify_password_bounded("x", "y", key="203.0.113.9") is False async def test_bounded_slot_freed_by_the_work_not_by_cancellation( @@ -198,7 +218,7 @@ async def test_bounded_slot_freed_by_the_work_not_by_cancellation( monkeypatch.setattr(password_mod, "verify_password", _blocked) - task = asyncio.create_task(verify_password_bounded("x", "y")) + task = asyncio.create_task(verify_password_bounded("x", "y", key="203.0.113.1")) await asyncio.to_thread(started.wait, 5) task.cancel() @@ -206,12 +226,162 @@ async def test_bounded_slot_freed_by_the_work_not_by_cancellation( await task # Работа всё ещё занимает поток — слот занят, следующий получает отказ. + # Ключ ДРУГОЙ: отказ обязан прийти от общего потолка (max_inflight=1), а не + # от доли на ключ — иначе тест проверял бы не тот механизм. with pytest.raises(PasswordVerifyOverloadedError): - await verify_password_bounded("x", "y") + await verify_password_bounded("x", "y", key="203.0.113.2") finish.set() for _ in range(100): # дать колбэку доехать до цикла await asyncio.sleep(0.01) if settings.login_password_verify_max_inflight > password_mod._verify_inflight: break - assert await verify_password_bounded("x", "y") is False + # Отменённая работа вернула И общий слот, И слот своего ключа: тот же адрес + # снова обслуживается (утечка по ключу при пуле в 1 поток была бы вечным + # отказом именно этому адресу и больше ничем себя не проявила). + assert await verify_password_bounded("x", "y", key="203.0.113.1") is False + + +async def test_bounded_frees_slot_when_verify_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Исключение внутри сверки тоже возвращает слот — оба счётчика. + + `verify_password` глотает ValueError/TypeError сама, так что сюда доезжает + только неожиданное (падение библиотеки, MemoryError). Пул из ОДНОГО потока + не прощает: один невозвращённый слот — вечный 429 всем на входе, и внешне + это выглядит не как ошибка bcrypt, а как «вход сломался неизвестно почему». + """ + + def _boom(plain: str, hashed: str) -> bool: + raise MemoryError("bcrypt died") + + monkeypatch.setattr(password_mod, "verify_password", _boom) + with pytest.raises(MemoryError): + await verify_password_bounded("x", "y", key="10.0.0.3") + + monkeypatch.setattr(password_mod, "verify_password", lambda plain, hashed: False) + assert await verify_password_bounded("x", "y", key="10.0.0.3") is False + assert password_mod._verify_inflight == 0 + assert not password_mod._verify_inflight_by_key + + +async def test_one_key_cannot_take_more_than_its_share(monkeypatch: pytest.MonkeyPatch) -> None: + """Один ключ занимает не больше своей доли — остальным ёмкость остаётся (#2714). + + Меряем именно ЭТО, а не латентность: с общим котлом слотов один источник + выбирал его целиком, и вход с другого адреса получал 429 бессрочно — + потолок темпа исправно работал против легитимных пользователей. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 4) + assert password_mod._per_key_slot_cap() == 2 # 4 // 2 — исходные условия теста + + finish = threading.Event() + + def _blocked(plain: str, hashed: str) -> bool: + finish.wait(5) + return False + + monkeypatch.setattr(password_mod, "verify_password", _blocked) + + async def _wait_inflight(n: int) -> None: + deadline = time.monotonic() + 5 + while password_mod._verify_inflight < n: + assert ( + time.monotonic() < deadline + ), f"слотов занято {password_mod._verify_inflight} < {n}" + await asyncio.sleep(0.005) + + flood = [ + asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.1")) for _ in range(2) + ] + await _wait_inflight(2) + + # Третий с ТОГО ЖЕ адреса — отказ, хотя два слота из четырёх свободны. + # Это ЦЕНА правки, а не побочный эффект: три одновременных входа из одного + # офиса за NAT укладываются в окно одной сверки (282 мс), и третьему + # сотруднику теперь отказывают при наполовину пустом пуле — до правки для + # этого требовалось пятеро. Закрепляем явно, чтобы размен нельзя было + # потерять молча: свои с ЧУЖИХ адресов за это получают 100% вместо 37%. + assert password_mod._verify_inflight == 2 + assert settings.login_password_verify_max_inflight == 4 + with pytest.raises(PasswordVerifyOverloadedError): + await verify_password_bounded("x", "y", key="10.0.0.1") + + # А с другого — пускают. Задачу ставим ДО finish.set() и ждём, пока она + # займёт слот: иначе «пустили» означало бы только «флуд успел закончиться». + legit = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.2")) + await _wait_inflight(3) + + finish.set() + assert await legit is False, "вход с другого адреса обязан пройти во время флуда" + assert [await f for f in flood] == [False, False] + + +def test_per_key_cap_never_rounds_down_to_zero(monkeypatch: pytest.MonkeyPatch) -> None: + """При очереди в 1 слот доля не округляется в ноль. + + `1 // 2 == 0` означало бы «ни одному ключу нельзя ни одного слота» — + молчаливый отказ ВСЕМ на входе, причём тем более незаметный, что настройка + выглядит как безобидное ужесточение. `max(1, …)` — тот же страховочный пол, + что `ge=1` у самой настройки, только от деления. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 1) + assert password_mod._per_key_slot_cap() == 1 + + +async def test_bounded_frees_slot_when_pool_refuses_work(monkeypatch: pytest.MonkeyPatch) -> None: + """Пул не принял работу → слот отдан прямо здесь, колбэка ведь не будет. + + Единственный путь, где освобождение НЕ висит на future: `submit` бросает + (пул закрыт на остановке процесса). Утечка тут стоила бы дорого — при пуле + в один поток невозвращённый слот это вечный 429 всем на входе. + """ + dead_pool = ThreadPoolExecutor(max_workers=1) + dead_pool.shutdown() + monkeypatch.setattr(password_mod, "_VERIFY_POOL", dead_pool) + + with pytest.raises(RuntimeError): + await verify_password_bounded("x", "y", key="10.0.0.4") + + assert password_mod._verify_inflight == 0 + assert not password_mod._verify_inflight_by_key + + +async def test_cancelling_queued_work_returns_the_key_slot(monkeypatch: pytest.MonkeyPatch) -> None: + """Отмена ЕЩЁ НЕ НАЧАТОЙ работы возвращает слот — и общий, и ключа. + + Ветка future другая, чем у отмены начатой работы (`cancel()` на очереди + успевает, и работа не исполняется вовсе), поэтому проверяется отдельно: + соседний тест про начатую работу эту не покрывает. Пул из одного потока — + настоящий, так что второй запрос гарантированно ЖДЁТ в очереди. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 4) + started = threading.Event() + finish = threading.Event() + + def _blocked(plain: str, hashed: str) -> bool: + started.set() + finish.wait(5) + return False + + monkeypatch.setattr(password_mod, "verify_password", _blocked) + + running = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.5")) + await asyncio.to_thread(started.wait, 5) + + queued = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.6")) + deadline = time.monotonic() + 5 + while password_mod._verify_inflight_by_key.get("10.0.0.6") != 1: + assert time.monotonic() < deadline, "второй запрос не занял слот" + await asyncio.sleep(0.005) + + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + + deadline = time.monotonic() + 5 + while "10.0.0.6" in password_mod._verify_inflight_by_key: + assert time.monotonic() < deadline, "слот отменённой очереди не вернулся" + await asyncio.sleep(0.005) + + finish.set() + assert await running is False From 3231233b73ecb6fc886167077664849e14c5382b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 11:13:17 +0000 Subject: [PATCH 048/130] =?UTF-8?q?fix(tradein/browser):=20=D1=87=D0=B5?= =?UTF-8?q?=D1=82=D1=8B=D1=80=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=D0=B0=D0=B9=D0=B4=D0=BA=D0=B0=D1=80=D0=B0=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=BD=D1=8F=D1=8E=D1=82=20=D0=BA=D0=BE=D0=B4=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D0=B5=D1=81=D1=82=20=D0=BE=D1=82=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BB,=20=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=B4=20=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D0=BC=D0=B0=D0=BD=20(#2722)=20(#2724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/browser/test_server.py | 17 +++++++++++------ tradein-mvp/browser/test_server_smoke.py | 8 ++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 399f6722..2f1438eb 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -372,7 +372,11 @@ def test_resource_block_handler_degrades_to_continue(monkeypatch: pytest.MonkeyP def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: """Первый goto провайдера — без ожидания, но timestamp пишется.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + # Интервал задаём в _MIN_PAGE_INTERVAL_BY_PROVIDER — ИМЕННО его читает + # _pace_provider (per-provider override, #1812 follow-up). Модульный + # BROWSER_MIN_PAGE_INTERVAL_S там только фолбэк для провайдера вне словаря, + # так что патч глобала на известном провайдере не влияет ни на что. + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -386,7 +390,7 @@ def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> N def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) -> None: """Второй goto в пределах интервала → sleep на остаток (interval - elapsed).""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -415,7 +419,7 @@ def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.MonkeyPatch) -> None: """Если с прошлого goto прошло >= интервала → не спим.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "yandex", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -440,8 +444,8 @@ def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.Monkey def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None: - """BROWSER_MIN_PAGE_INTERVAL_S <= 0 → пейсинг выключен, timestamp не пишется.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0) + """Эффективный интервал провайдера <= 0 → пейсинг выключен, timestamp не пишется.""" + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 0.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -460,7 +464,8 @@ def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> No def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) -> None: """Timestamp независим по провайдерам — пейсинг avito не влияет на cian.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py index 43208a73..13c8527d 100644 --- a/tradein-mvp/browser/test_server_smoke.py +++ b/tradein-mvp/browser/test_server_smoke.py @@ -89,7 +89,7 @@ def test_fetch_returns_503_when_browser_unavailable( Никакого реального camoufox-launch'а / direct-fetch. """ - async def _no_ensure(provider: str) -> bool: + async def _no_ensure(provider: str, proxy_override: str | None = None) -> bool: return False # прокси всё ещё недоступен → браузер остаётся None monkeypatch.setattr(server, "_ensure_browser", _no_ensure) @@ -111,7 +111,7 @@ def test_login_returns_503_when_browser_unavailable( ) -> None: """/login тоже отдаёт 503 когда инстанс не поднят (без credential-логирования).""" - async def _no_ensure(provider: str) -> bool: + async def _no_ensure(provider: str, proxy_override: str | None = None) -> bool: return False monkeypatch.setattr(server, "_ensure_browser", _no_ensure) @@ -193,7 +193,7 @@ def test_fetch_opens_and_closes_own_page(monkeypatch: pytest.MonkeyPatch) -> Non server._browsers["avito"] = _FakeBrowser(tracker) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) - async def _ensure(provider: str) -> bool: + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_ensure_browser", _ensure) @@ -221,7 +221,7 @@ def test_fetch_recycles_after_threshold(monkeypatch: pytest.MonkeyPatch) -> None relaunched["n"] += 1 server._page_counters[provider] = 0 - async def _ensure(provider: str) -> bool: + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_relaunch_browser", _fake_relaunch) From 3527d2b608ce12104b4c33c1a5109056973a7ee7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 11:19:50 +0000 Subject: [PATCH 049/130] =?UTF-8?q?ci(tradein):=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=81=D0=B0=D0=B9=D0=B4=D0=BA=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=BA=D0=BE=D0=BD=D0=B5=D1=86=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B3=D1=83=D1=82=20=D0=B2=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=D0=B5=20(#2722)=20(#2726)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/ci-tradein.yml | 55 +++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 94b9d2ff..c74ceca6 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -30,6 +30,7 @@ jobs: outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} + browser: ${{ steps.filter.outputs.browser }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -54,6 +55,12 @@ jobs: frontend: - 'tradein-mvp/frontend/**' - '.forgejo/workflows/ci-tradein.yml' + browser: + # Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml + # members = backend + packages/*), со своим Dockerfile и без pyproject, + # поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе. + - 'tradein-mvp/browser/**' + - '.forgejo/workflows/ci-tradein.yml' backend-tests: runs-on: ubuntu-latest @@ -106,6 +113,54 @@ jobs: uv run pytest -q \ --deselect "tests/test_search_api.py::test_search_cache_hit" + # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: + # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог + # вне uv-воркспейса, так что и `uv run pytest` из backend их не собирал. Итог: + # 4 теста лежали красными на main (с 2026-06-20 и 2026-07-02), файл при этом + # правился, и никто не узнал. Починка — PR #2724, этот job закрывает причину. + # + # Почему НЕ переиспользуем backend-job: + # 1. сайдкар не член воркспейса → `uv sync --frozen` его не ставит; + # 2. aiohttp (единственная не-stdlib зависимость сьюта) нет в tradein-mvp/uv.lock; + # 3. разный scope paths-filter: правка browser/ не должна гонять backend-сьют. + browser-tests: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.browser == 'true' + # Сьют идёт ~15с. Лимит — страховка от зависшего теста: у сайдкара нет своего + # pyproject, а значит и pytest-timeout'а backend'а (timeout=120). Дешевле + # взять нативный job-таймаут, чем тащить плагин ради одного каталога. + timeout-minutes: 10 + defaults: + run: + working-directory: ./tradein-mvp/browser + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + # 3.12 — как в browser/Dockerfile (FROM python:3.12-slim). + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install test deps + # ВЕСЬ список: pytest + aiohttp. Ни playwright, ни camoufox, ни закачки + # Firefox — camoufox импортируется ЛЕНИВО внутри _launch_browser + # (server.py, `from camoufox.async_api import AsyncCamoufox`), а сами тесты + # мокают _ensure_browser/_do_fetch и грузят server.py по пути через importlib. + # pytest-asyncio тоже НЕ нужен: ни одного `async def test_` — каждый тест сам + # крутит asyncio.run(). Проверено локально на venv ровно из этих двух пакетов. + # + # aiohttp без пина — ровно как в browser/Dockerfile (`pip install ... aiohttp`), + # то есть гейт видит ту же версию, что уедет в образ. Пин здесь означал бы + # проверку версии, которой в проде нет. + run: pip install pytest aiohttp + + - name: Run pytest (tradein-mvp/browser) + # Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего + # не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped. + run: pytest -q + frontend-checks: runs-on: ubuntu-latest needs: changes From 02b256288db669e75ee4e674d0bd10212c36a8b8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 11:30:42 +0000 Subject: [PATCH 050/130] =?UTF-8?q?fix(tradein):=20=D0=BC=D0=BE=D0=BB?= =?UTF-8?q?=D1=87=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B5=D1=81=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=87=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B5=D0=BD,=20deselect?= =?UTF-8?q?=20=D1=81=D0=BD=D1=8F=D1=82=20(#2722)=20(#2729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/ci-tradein.yml | 28 +++++++++++++------- tradein-mvp/backend/tests/test_search_api.py | 2 +- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index c74ceca6..c3f6bcda 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -103,15 +103,25 @@ jobs: run: uv sync --frozen - name: Run pytest (tradein-mvp/backend) - # DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает - # ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state - # leak из другого test-модуля, pre-existing. Второй исторический deselect - # (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в - # полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан - # совпадать с test-job в deploy-tradein.yml. - run: | - uv run pytest -q \ - --deselect "tests/test_search_api.py::test_search_cache_hit" + # БЕЗ deselect'ов — сьют гоняется целиком (#2722). + # + # Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit` + # с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит — + # global-state leak из другого модуля». Объяснение было неверным в обеих + # половинах: тест падал и в изоляции тоже (401 vs 200), потому что он — + # единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка + # X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что + # фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке; + # заголовок добавлен, deselect снят, полный прогон зелёный. + # + # Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же + # класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо + # помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом. + # + # NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого + # deselect'а — он остаётся до #2680, который правит тот файл. Расхождение + # безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный. + run: uv run pytest -q # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог diff --git a/tradein-mvp/backend/tests/test_search_api.py b/tradein-mvp/backend/tests/test_search_api.py index 5ba489bd..e6c6c1ad 100644 --- a/tradein-mvp/backend/tests/test_search_api.py +++ b/tradein-mvp/backend/tests/test_search_api.py @@ -164,7 +164,7 @@ def test_search_cache_hit(_reset_cache_singleton): } _reset_cache_singleton.get = AsyncMock(return_value=cached_payload) client = TestClient(app) - r = client.post("/api/v1/search", json={"rooms": 2}) + r = client.post("/api/v1/search", json={"rooms": 2}, headers={"X-Authenticated-User": "admin"}) assert r.status_code == 200 assert r.json()["cache_hit"] is True _reset_cache_singleton.set.assert_not_awaited() From 5f71fc670ff3bb4d08dca38e547cbfaaae629b68 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 11:30:52 +0000 Subject: [PATCH 051/130] =?UTF-8?q?fix(tradein/scraper):=20=D1=81=D0=B8?= =?UTF-8?q?=D0=B3=D0=BD=D0=B0=D0=BB=20=D0=B6=D0=B8=D0=B2=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B7=20=D1=81=D0=B5=D1=80=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B1=D0=B0=D1=82=D1=87=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=20=D1=87=D0=B8=D1=81=D0=BB=D0=B8=D1=82=D1=8C=D1=81=D1=8F?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D1=88=D0=B8=D0=BC=D0=B8=20(#2?= =?UTF-8?q?725)=20(#2727)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/scheduler.py | 48 +++- .../app/tasks/cian_history_backfill.py | 20 ++ .../app/tasks/newbuilding_enrich_backfill.py | 25 +- .../tasks/test_newbuilding_enrich_backfill.py | 6 +- .../tests/test_2725_heartbeat_in_batch.py | 225 ++++++++++++++++++ 5 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 3e715dd3..b1a43824 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -76,7 +76,16 @@ async def _execute_cian_backfill( """Orchestrate Cian history backfill with heartbeat + checkpoint. Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat) - before and after the batch call for zombie-detection visibility. + НА КАЖДОЙ сущности батча, а не только до и после него (#2725). Раньше сигнал + живости слался ровно один раз — до батча, — а `reap_zombies` меряет именно + heartbeat_at с порогом 6 ч, и добивал живые прогоны строго на 6-м часу: 6 прод- + прогонов этого источника помечены 'zombie' со сдвигом heartbeat 16-32 мс, при том + что у пятерых внутри окна писались строки offer_price_history (у прогона 304 — до + 5.4 ч после старта), а штатная длительность источника доходит до 5.06 ч (346). + Цена ошибки не косметическая: mark_done апдейтит WHERE status='running', так что + после ложной пометки собственный финал прогона становится no-op (отсюда нулевые + counters у всех шести), а has_running_run перестаёт видеть прогон и следующий тик + может запустить второй такой же батч поверх работающего. Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS NULL via LEFT JOIN — so re-running after a partial completion naturally skips @@ -85,9 +94,33 @@ async def _execute_cian_backfill( Params (from default_params jsonb): batch_size: int — rows per run (listings + houses counted separately). """ - from app.tasks.cian_history_backfill import backfill_cian_history + from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history batch_size = int(params.get("batch_size", 100)) + + def _counters(result: CianBackfillResult) -> dict[str, int]: + return { + "listings_processed": result.listings_processed, + "listings_succeeded": result.listings_succeeded, + "listings_failed": result.listings_failed_fetch + result.listings_failed_save, + "houses_processed": result.houses_processed, + "houses_succeeded": result.houses_succeeded, + "houses_failed": result.houses_failed_fetch + result.houses_failed_save, + } + + def _heartbeat(progress: CianBackfillResult) -> None: + """Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен + ронять уже идущую работу — прогон в худшем случае вернётся к прежнему + поведению (пометка 'zombie' на 6-м часу).""" + try: + runs_mod.update_heartbeat(db, run_id, _counters(progress)) + except Exception: + logger.warning( + "scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)", + run_id, + exc_info=True, + ) + counters: dict[str, int] = { "listings_processed": 0, "listings_succeeded": 0, @@ -106,17 +139,10 @@ async def _execute_cian_backfill( do_listings=True, do_houses=True, do_valuations=False, + on_progress=_heartbeat, ) - counters = { - "listings_processed": result.listings_processed, - "listings_succeeded": result.listings_succeeded, - "listings_failed": result.listings_failed_fetch + result.listings_failed_save, - "houses_processed": result.houses_processed, - "houses_succeeded": result.houses_succeeded, - "houses_failed": result.houses_failed_fetch + result.houses_failed_save, - "duration_sec": int(result.duration_sec), - } + counters = {**_counters(result), "duration_sec": int(result.duration_sec)} runs_mod.mark_done(db, run_id, counters) logger.info( "scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", diff --git a/tradein-mvp/backend/app/tasks/cian_history_backfill.py b/tradein-mvp/backend/app/tasks/cian_history_backfill.py index d76f2be9..b6c5907b 100644 --- a/tradein-mvp/backend/app/tasks/cian_history_backfill.py +++ b/tradein-mvp/backend/app/tasks/cian_history_backfill.py @@ -18,6 +18,14 @@ Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column). Rate limit: scraper_settings.get_scraper_delay('cian') between requests. + +Сигнал живости (#2725): батч дёргает `on_progress` на КАЖДОЙ сущности — caller +переливает это в scrape_runs.heartbeat_at. Пока колбэка не было, планировщик слал +heartbeat один раз ДО батча, а `reap_zombies` меряет ровно heartbeat с порогом 6 ч — +и добивал живые прогоны строго на 6-м часу (6 прод-прогонов, у пятерых внутри окна +писались строки offer_price_history, у одного — до 5.4 ч после старта). Ослаблять +критерий нельзя: пометка 'zombie' снимает running-блокировку источника +(`has_running_run`), без неё зависший прогон запер бы источник навсегда. """ from __future__ import annotations @@ -25,6 +33,7 @@ from __future__ import annotations import asyncio import logging import time +from collections.abc import Callable from dataclasses import dataclass, field from scraper_kit.browser_fetcher import BrowserFetcher @@ -68,6 +77,7 @@ async def backfill_cian_history( do_houses: bool = True, do_valuations: bool = False, dry_run: bool = False, + on_progress: Callable[[CianBackfillResult], None] | None = None, ) -> CianBackfillResult: """Iterate Cian listings + houses with missing history, fetch+save. @@ -82,6 +92,10 @@ async def backfill_cian_history( do_valuations: process Cian Valuation Calculator batch (external_valuations backfill). Default False — opt-in because each call hits Cian auth-gated API. dry_run: skip all fetch+save; only count and log pending rows. + on_progress: колбэк живости (#2725) — вызывается на каждой сущности ЛЮБОГО из + трёх блоков, до её обработки, с текущим (мутируемым) result. Caller пишет + heartbeat; исключения колбэка — на его совести (планировщик глушит их сам), + здесь они прервали бы батч. Returns: CianBackfillResult with per-domain counters + total wall-clock duration. @@ -120,6 +134,8 @@ async def backfill_cian_history( listing_id: int = row["id"] source_url: str = row["source_url"] result.listings_processed += 1 + if on_progress is not None: + on_progress(result) enrichment = None try: @@ -209,6 +225,8 @@ async def backfill_cian_history( house_id: int = hrow["id"] zhk_url: str = hrow["cian_zhk_url"] result.houses_processed += 1 + if on_progress is not None: + on_progress(result) enrichment = None try: @@ -291,6 +309,8 @@ async def backfill_cian_history( else: for row in rows: result.valuations_processed += 1 + if on_progress is not None: + on_progress(result) try: cval = await estimate_via_cian_valuation( db, diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index b6f5dda1..e46fe9d9 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -66,6 +66,7 @@ import json import logging import random import time +from collections.abc import Callable from dataclasses import dataclass, field, fields from sqlalchemy import text @@ -306,8 +307,7 @@ def _house_enrichment_counts(db: Session, house_id: int) -> tuple[int, int, int] rc = int( db.execute( text( - "SELECT COUNT(*) FROM house_reliability_checks " - "WHERE house_id = CAST(:h AS bigint)" + "SELECT COUNT(*) FROM house_reliability_checks WHERE house_id = CAST(:h AS bigint)" ), {"h": house_id}, ).scalar_one() @@ -328,6 +328,7 @@ async def backfill_newbuilding_enrichment( force: bool = False, request_delay_sec: float | None = None, dry_run: bool = False, + on_progress: Callable[[NewbuildingEnrichBackfillResult], None] | None = None, ) -> NewbuildingEnrichBackfillResult: """Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses. @@ -347,6 +348,11 @@ async def backfill_newbuilding_enrichment( (default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing a resolve incurs TWO delays (resolve fetch + enrich fetch). dry_run: count the population + log the pending list, fetch nothing, write nothing. + on_progress: колбэк живости (#2725) — вызывается на каждом доме с текущим + (мутируемым) result; caller пишет scrape_runs.heartbeat_at. Без него + heartbeat уходил один раз до цикла, а `reap_zombies` меряет именно его: + дом обходится за ~2.6 мин, и на limit'е порядка 140 (полный прогон — 318 + домов, см. выше) прогон переваливал бы 6-часовой порог живым. Returns: NewbuildingEnrichBackfillResult with population sizing, per-house outcome @@ -415,6 +421,8 @@ async def backfill_newbuilding_enrichment( zhk_url: str | None = row["cian_zhk_url"] ext_id: str | None = row["ext_id"] result.processed += 1 + if on_progress is not None: + on_progress(result) # Idempotency fast-path: with force=False the SELECT already excludes enriched # houses (price_dynamics + reliability present), so this branch is a belt-and- @@ -696,6 +704,18 @@ async def run_newbuilding_enrich( "failed_save": 0, } + def _heartbeat(progress: NewbuildingEnrichBackfillResult) -> None: + """Сигнал живости из середины цикла (#2725). Best-effort — сбой heartbeat не + должен ронять уже идущий обход.""" + try: + runs_mod.update_heartbeat(db, run_id, progress.to_dict()) + except Exception: + logger.warning( + "scheduler: newbuilding_enrich run_id=%d heartbeat failed (ignored)", + run_id, + exc_info=True, + ) + try: runs_mod.update_heartbeat(db, run_id, counters) @@ -704,6 +724,7 @@ async def run_newbuilding_enrich( limit=limit, force=force, request_delay_sec=request_delay_sec, + on_progress=_heartbeat, ) counters = result.to_dict() diff --git a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py index 16c32b25..4df0d729 100644 --- a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py @@ -541,7 +541,8 @@ async def test_run_wrapper_marks_done_and_passes_params(monkeypatch: pytest.Monk """run_newbuilding_enrich emits heartbeat → delegates with parsed params → mark_done.""" seen: dict = {} - async def _fake_backfill(_db, *, limit, force, request_delay_sec): + async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None): + # on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем. seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec) return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_rows=2) @@ -575,7 +576,8 @@ async def test_run_wrapper_defaults_when_params_empty(monkeypatch: pytest.Monkey """Empty default_params → limit=25, force=False, request_delay_sec=None (→ scraper delay).""" seen: dict = {} - async def _fake_backfill(_db, *, limit, force, request_delay_sec): + async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None): + # on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем. seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec) return NewbuildingEnrichBackfillResult() diff --git a/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py b/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py new file mode 100644 index 00000000..a0450aa2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py @@ -0,0 +1,225 @@ +"""#2725: сигнал живости слался один раз — до батча, — и живые прогоны reap'ились. + +Что было. `_execute_cian_backfill` дёргал `update_heartbeat` ровно один раз, ДО +`backfill_cian_history()`, а сам батч (до 100 объявлений + 37 домов, каждое — fetch +через браузер + пауза ~5 с) heartbeat не трогал. `reap_zombies` меряет именно +`heartbeat_at` с порогом ZOMBIE_THRESHOLD_HOURS = 6 ч → прогон помечался 'zombie' +строго на 6-м часу независимо от того, работает он или висит. + +Прод-замер 2026-08-06: 6 прогонов `cian_history_backfill` со статусом 'zombie', у всех +шести сдвиг heartbeat 16-32 мс (= единственный стартовый вызов) и финал ровно на +started_at + 6.00 ч. Живыми они при этом были: внутри окна пятерых писались строки +offer_price_history с source='cian' (98/523/38/60/83 — плановый писатель этих строк +только этот батч), у прогона 304 последняя строка легла через 5.40 ч после старта. +Штатная длительность источника доходит до 5.06 ч (прогон 346, counters.duration_sec +18230) — то есть источник ходит вплотную к порогу. + +Почему чинится сигнал, а не критерий: пометка 'zombie' снимает running-блокировку +источника (`has_running_run` видит только status='running'), и без неё зависший +прогон запер бы источник навсегда. Плюс `mark_done` апдейтит `WHERE status='running'`, +поэтому после ложной пометки собственный финал прогона — no-op (отсюда нулевые +counters у всех шести строк). + +Фальсификация: на старом коде тест 2 падает — планировщик не передавал `on_progress`, +батч heartbeat не двигал, и к концу 7-часовой работы возраст сигнала = 7 ч > 6 ч. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.scheduler import ZOMBIE_THRESHOLD_HOURS + +from app.services import scheduler as sched_mod +from app.tasks import cian_history_backfill + + +def _would_be_reaped(heartbeat_at: datetime, now: datetime) -> bool: + """Критерий reap_zombies дословно: heartbeat старше порога → 'zombie'.""" + return heartbeat_at < now - timedelta(hours=ZOMBIE_THRESHOLD_HOURS) + + +class _FakeBrowserFetcher: + def __init__(self, **kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _FakeBrowserFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + +# ── 1. Батч сообщает о продвижении на каждой сущности ──────────────────────── +async def test_batch_reports_progress_per_entity() -> None: + db = MagicMock() + db.execute.return_value.mappings.return_value.all.side_effect = [ + [ + {"id": 1, "source_url": "https://cian.ru/1"}, + {"id": 2, "source_url": "https://cian.ru/2"}, + ], + [{"id": 10, "cian_zhk_url": "https://cian.ru/zhk-10"}], + ] + seen: list[tuple[int, int]] = [] + + with ( + patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher), + patch.object( + cian_history_backfill, + "fetch_detail", + AsyncMock(return_value=SimpleNamespace(price_changes=[])), + ), + patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()), + patch( + "scraper_kit.providers.cian.newbuilding.fetch_newbuilding", + AsyncMock(return_value=SimpleNamespace()), + ), + patch("scraper_kit.providers.cian.newbuilding.save_newbuilding_enrichment", MagicMock()), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + result = await cian_history_backfill.backfill_cian_history( + db, + do_listings=True, + do_houses=True, + do_valuations=False, + on_progress=lambda r: seen.append((r.listings_processed, r.houses_processed)), + ) + + # По одному сигналу на каждую сущность обоих блоков, счётчики растут. + assert seen == [(1, 0), (2, 0), (2, 1)] + assert result.listings_processed == 2 + assert result.houses_processed == 1 + + +# ── 2. Долгий прогон с продвигающимся heartbeat не помечается зависшим ─────── +async def test_long_run_with_advancing_heartbeat_is_not_reaped() -> None: + """7 часов работы, час на сущность: планировщик обязан двигать heartbeat.""" + t0 = datetime(2026, 6, 26, 4, 47, tzinfo=UTC) + clock = SimpleNamespace(now=t0) + beats: list[datetime] = [] + + async def _fake_batch(db: Any, **kwargs: Any) -> Any: + on_progress = kwargs.get("on_progress") + result = cian_history_backfill.CianBackfillResult() + for _ in range(7): # 7 сущностей по часу — дольше 6-часового порога + clock.now += timedelta(hours=1) + result.listings_processed += 1 + if on_progress is not None: + on_progress(result) + result.duration_sec = 7 * 3600 + return result + + fake_runs = SimpleNamespace( + update_heartbeat=lambda db, run_id, counters: beats.append(clock.now), + mark_done=MagicMock(), + mark_failed=MagicMock(), + ) + + with ( + patch.object(sched_mod, "runs_mod", fake_runs), + patch.object(cian_history_backfill, "backfill_cian_history", _fake_batch), + ): + await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={}) + + assert len(beats) == 8, "стартовый сигнал + по одному на сущность" + reaped = _would_be_reaped(beats[-1], clock.now) + assert not reaped, "живой прогон с продвигающимся heartbeat не должен reap'иться" + assert fake_runs.mark_done.called + + +# ── 3. Прогон без продвижения — помечается (контроль критерия) ─────────────── +async def test_long_run_without_advancing_heartbeat_is_reaped() -> None: + """Тот же прогон, но батч сигнала не шлёт — критерий обязан сработать.""" + t0 = datetime(2026, 6, 26, 4, 47, tzinfo=UTC) + clock = SimpleNamespace(now=t0) + beats: list[datetime] = [] + + async def _mute_batch(db: Any, **kwargs: Any) -> Any: + clock.now += timedelta(hours=7) # работает, но молча + return cian_history_backfill.CianBackfillResult() + + fake_runs = SimpleNamespace( + update_heartbeat=lambda db, run_id, counters: beats.append(clock.now), + mark_done=MagicMock(), + mark_failed=MagicMock(), + ) + + with ( + patch.object(sched_mod, "runs_mod", fake_runs), + patch.object(cian_history_backfill, "backfill_cian_history", _mute_batch), + ): + await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={}) + + assert beats == [t0], "единственный сигнал — стартовый" + assert _would_be_reaped(beats[-1], clock.now) + + +# ── 4. Сбой heartbeat не роняет уже идущую работу ──────────────────────────── +async def test_heartbeat_failure_does_not_abort_the_batch() -> None: + processed: list[int] = [] + calls = {"n": 0} + + def _flaky_heartbeat(db: Any, run_id: int, counters: dict[str, int]) -> None: + calls["n"] += 1 + if calls["n"] > 1: # стартовый прошёл, дальше БД отвалилась + raise Exception("DB gone") + + async def _fake_batch(db: Any, **kwargs: Any) -> Any: + on_progress = kwargs["on_progress"] + result = cian_history_backfill.CianBackfillResult() + for _ in range(3): + result.listings_processed += 1 + on_progress(result) # обязан проглотить исключение внутри себя + processed.append(result.listings_processed) + return result + + fake_runs = SimpleNamespace( + update_heartbeat=_flaky_heartbeat, + mark_done=MagicMock(), + mark_failed=MagicMock(), + ) + + with ( + patch.object(sched_mod, "runs_mod", fake_runs), + patch.object(cian_history_backfill, "backfill_cian_history", _fake_batch), + ): + await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={}) + + assert processed == [1, 2, 3] + assert fake_runs.mark_done.called + + +# ── 5. Тот же дефект у newbuilding_enrich — сигнал прокинут ────────────────── +async def test_newbuilding_enrich_passes_progress_callback() -> None: + from app.tasks import newbuilding_enrich_backfill as nb + + beats: list[dict[str, int]] = [] + + async def _fake_backfill(db: Any, **kwargs: Any) -> Any: + on_progress = kwargs.get("on_progress") + assert on_progress is not None, "планировщик обязан прокинуть сигнал живости" + result = nb.NewbuildingEnrichBackfillResult() + result.processed += 1 + on_progress(result) + return result + + fake_runs = SimpleNamespace( + update_heartbeat=lambda db, run_id, counters: beats.append(counters), + mark_done=MagicMock(), + mark_failed=MagicMock(), + ) + + with ( + patch.object(nb, "runs_mod", fake_runs), + patch.object(nb, "backfill_newbuilding_enrichment", _fake_backfill), + ): + await nb.run_newbuilding_enrich(MagicMock(), run_id=1, params={}) + + assert len(beats) == 2, "стартовый сигнал + сигнал из середины цикла" + assert beats[-1]["processed"] == 1 From 2a1577738aad233557ba9844d799054fea2dc2a3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 11:41:39 +0000 Subject: [PATCH 052/130] =?UTF-8?q?chore(tradein/db):=20DROP=20listings=5F?= =?UTF-8?q?snapshots.position=5Fin=5Fserp=20=E2=80=94=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=202=20=D0=B8=D0=B7=202=20(#2697)=20(#2728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/sql/227_drop_position_in_serp.sql | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql diff --git a/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql b/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql new file mode 100644 index 00000000..8f9e2a8a --- /dev/null +++ b/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql @@ -0,0 +1,41 @@ +-- 227_drop_position_in_serp.sql +-- listings_snapshots.position_in_serp — DROP, шаг 2 из 2 (#2697, продолжение #2674). +-- +-- Dependencies: 217_position_in_serp_unexpressible.sql (диагноз + COMMENT на колонке). +-- Apply after: 224_houses_house_type_canon.sql +-- Идемпотентно: DROP COLUMN IF EXISTS. +-- +-- ── ПОЧЕМУ УДАЛЯЕМ, А НЕ ПОДКЛЮЧАЕМ ────────────────────────────────────────── +-- Полный разбор — в 217. Кратко: позиция есть свойство пары (объявление, конкретный +-- прогон выдачи с конкретными фильтрами), а PRIMARY KEY (listing_id, snapshot_date) +-- держит одну строку на объявление в сутки — при том что за 2026-08-03 по этому ключу +-- писали 13 разных run_id и четыре SERP-источника, а внутри одного city_sweep +-- объявление приезжает с разным индексом от перекрывающихся гео-якорей. Значение +-- оседало бы от последнего писателя дня и читалось бы как факт. Честное хранение — +-- отдельная таблица с ключом (run_id, listing_id) и сохранёнными фильтрами прогона, +-- то есть НЕ возврат этой колонки. +-- +-- ── ПРЕДУСЛОВИЕ ПРОВЕРЕНО ПЕРЕД МЕРЖЕМ (2026-08-06) ────────────────────────── +-- Окно «SQL применяется ДО перезапуска контейнеров» закрыто тем, что правка кода +-- (#2694) уже живёт на проде — проверено ПО КОДУ В КОНТЕЙНЕРАХ, не по зелёному +-- деплою: grep по /app в tradein-scraper и tradein-backend находит имя колонки +-- ровно в четырёх строках docstring'а snapshot_writer.py (18/34/37/76) и ни в одном +-- SQL; inspect.signature(upsert_listing_snapshot) колонку не содержит. +-- Оба живых писателя перечисляют колонки явно и этой в списке не имеют: +-- * scraper_kit/snapshot_writer.py::upsert_listing_snapshot (весь скрейп-путь), +-- * app/tasks/deactivate_stale_avito.py::_STALE_SNAPSHOT_TAIL (TTL-снимки 'stale'). +-- +-- ── ЧИТАТЕЛЕЙ НЕТ ──────────────────────────────────────────────────────────── +-- На проде: 0 непустых значений из 397 217 строк; 0 view/matview зависят от колонки +-- (pg_depend → pg_rewrite); индексов и триггеров на ней нет; foreign table над +-- listings_snapshots не существует (FDW-обёртки только над gendesign-таблицами); +-- в information_schema.columns имя встречается ровно в этой таблице. В коде: ни +-- одного SELECT-читателя (`SELECT *` по таблице нигде нет), фронт/экспортёры/админка +-- колонку не упоминают. Единственный сторож — tests/test_snapshot_writer.py, он +-- проверяет отсутствие колонки у писателя и после DROP остаётся валиден. + +BEGIN; + +ALTER TABLE listings_snapshots DROP COLUMN IF EXISTS position_in_serp; + +COMMIT; From a32ccabd0d7865d33f2b616c113e70f1f651d7fc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 12:36:57 +0000 Subject: [PATCH 053/130] =?UTF-8?q?feat(tradein/payments):=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20Token,=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20=D0=A2-=D0=91=D0=B0=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=D0=B0=20(#2733)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-C платёжного контура: token.py (sign + verify_notification_token, оба эталонных вектора Т-Банка перепроверены независимо), receipt.py (54-ФЗ ФФД 1.05, целые копейки), tbank_client.py (Init/GetState/CheckOrder/Confirm/Cancel, таймаут 15с, 4xx не ретраится). Слой инертный: 0 импортёров, роутеров нет. Co-authored-by: bot-backend Co-committed-by: bot-backend --- .../backend/app/services/payments/__init__.py | 17 + .../backend/app/services/payments/receipt.py | 143 +++++++++ .../app/services/payments/tbank_client.py | 249 +++++++++++++++ .../backend/app/services/payments/token.py | 98 ++++++ .../tests/services/payments/__init__.py | 0 .../services/payments/test_tbank_client.py | 293 ++++++++++++++++++ .../backend/tests/test_payments_receipt.py | 208 +++++++++++++ .../backend/tests/test_payments_token.py | 198 ++++++++++++ 8 files changed, 1206 insertions(+) create mode 100644 tradein-mvp/backend/app/services/payments/__init__.py create mode 100644 tradein-mvp/backend/app/services/payments/receipt.py create mode 100644 tradein-mvp/backend/app/services/payments/tbank_client.py create mode 100644 tradein-mvp/backend/app/services/payments/token.py create mode 100644 tradein-mvp/backend/tests/services/payments/__init__.py create mode 100644 tradein-mvp/backend/tests/services/payments/test_tbank_client.py create mode 100644 tradein-mvp/backend/tests/test_payments_receipt.py create mode 100644 tradein-mvp/backend/tests/test_payments_token.py diff --git a/tradein-mvp/backend/app/services/payments/__init__.py b/tradein-mvp/backend/app/services/payments/__init__.py new file mode 100644 index 00000000..1f537849 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/__init__.py @@ -0,0 +1,17 @@ +"""Т-Банк интернет-эквайринг — чистый интеграционный слой (PR-C). + +Модули здесь НЕ импортируют `app.core.config` и не пишут в БД: все секреты +(`terminal_key`, `password`, `base_url`) принимаются аргументами функций/ +конструктора. Причина — параллельный PR-B вводит эти поля в `config.py`, +а проводку (роутер, `_PUBLIC_PATHS`, `payments`-таблицы, статус-машина) +делает следующий PR-D. См. `mera-tbank-acquiring-recon.md` (корень репо) +§3/§9 для полной схемы разбивки. + +- `token.py` — подпись `Token` запросов + проверка подписи нотификаций. +- `receipt.py` — сборка `Receipt` (54-ФЗ, ФФД 1.05) для услуги. +- `tbank_client.py` — httpx-клиент `Init/GetState/CheckOrder/Confirm/Cancel`. + +Docs: https://developer.tbank.ru/eacq/intro +""" + +from __future__ import annotations diff --git a/tradein-mvp/backend/app/services/payments/receipt.py b/tradein-mvp/backend/app/services/payments/receipt.py new file mode 100644 index 00000000..e1970dd7 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/receipt.py @@ -0,0 +1,143 @@ +"""Сборка объекта `Receipt` (54-ФЗ, ФФД 1.05) для чека Т-Банк эквайринга. + +Продукт продаёт УСЛУГУ (не товар) — везде фиксированы `PaymentObject="service"` +и `PaymentMethod="full_payment"` (одномоментная оплата за уже готовую услугу, +без предоплат/кредита/частичных расчётов). + +Схема (`Receipt` в `Init`, ФФД 1.05) — источник, снят живым запросом +2026-08-06: https://developer.tbank.ru/eacq/api/init + +- `Email` ИЛИ `Phone` — обязательно хотя бы одно (перекрёстный required). +- `Taxation` — обязателен: `osn|usn_income|usn_income_outcome|esn|patent`. +- `Items[].Name` — <=128 символов, обязателен. +- `Items[].Price`/`Quantity`/`Amount` — числа, В КОПЕЙКАХ; `Amount` — это + произведение `Price * Quantity` (дословно из API-reference). +- `Items[].Tax` — ставка НДС. Актуальный список 2026 (Init API reference): + `none|vat0|vat5|vat7|vat10|vat22|vat105|vat107|vat110|vat122`. + `vat20`/`vat120` В СПИСКЕ НЕТ — сняты, не использовать (см. recon §6/§11 + в `mera-tbank-acquiring-recon.md`, корень репо). + +ВАЖНО: `Receipt` НЕ участвует в расчёте `Token` (`token.py` отсекает любые +вложенные `dict`/`list` из подписи) — это архитектурно гарантировано самой +функцией `token.sign`, а не соглашением здесь. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +TaxRate = Literal[ + "none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122" +] + +Taxation = Literal["osn", "usn_income", "usn_income_outcome", "esn", "patent"] + +_ALLOWED_TAX_RATES: frozenset[str] = frozenset( + {"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"} +) +_ALLOWED_TAXATION: frozenset[str] = frozenset( + {"osn", "usn_income", "usn_income_outcome", "esn", "patent"} +) + +_MAX_ITEM_NAME_LEN = 128 +_MAX_ITEMS = 100 # "Количество товаров в чеке — не больше 100" (API reference) + + +class ReceiptBuildError(ValueError): + """Невалидные данные для сборки Receipt — не пройдёт валидацию Т-Банка.""" + + +@dataclass(frozen=True, slots=True) +class ReceiptItem: + """Одна позиция чека — услуга. `price_kopecks`/`quantity` — целые копейки/штуки.""" + + name: str + price_kopecks: int + quantity: int = 1 + tax: TaxRate = "none" + + @property + def amount_kopecks(self) -> int: + """Items[].Amount = Price * Quantity (дословно из API reference).""" + return self.price_kopecks * self.quantity + + def to_payload(self) -> dict[str, Any]: + if not self.name or len(self.name) > _MAX_ITEM_NAME_LEN: + raise ReceiptBuildError( + f"Items[].Name должен быть 1..{_MAX_ITEM_NAME_LEN} символов, " + f"получено {len(self.name)}" + ) + if self.price_kopecks <= 0: + raise ReceiptBuildError("Items[].Price должен быть > 0 (в копейках)") + if self.quantity <= 0: + raise ReceiptBuildError("Items[].Quantity должен быть > 0") + if self.tax not in _ALLOWED_TAX_RATES: + raise ReceiptBuildError( + f"Items[].Tax={self.tax!r} не входит в актуальный список Т-Банка " + f"({sorted(_ALLOWED_TAX_RATES)}) — vat20/vat120 сняты, не используются" + ) + return { + "Name": self.name, + "Price": self.price_kopecks, + "Quantity": self.quantity, + "Amount": self.amount_kopecks, + "Tax": self.tax, + "PaymentMethod": "full_payment", + "PaymentObject": "service", + } + + +def build_receipt( + *, + items: list[ReceiptItem], + taxation: Taxation, + email: str | None = None, + phone: str | None = None, +) -> dict[str, Any]: + """Собирает `Receipt` (ФФД 1.05) для одного заказа (может быть >1 позиции). + + Инвариант «сумма Items[].Amount == Init.Amount» здесь НЕ проверяется — + `Receipt` строится независимо от `Init`-payload заказа. Сверка — на + вызывающей стороне (`service.py`, следующий PR) через + `receipt_total_kopecks(receipt) == init_amount_kopecks`. См. тест + `test_receipt_total_matches_order_amount_invariant` в + `tests/test_payments_receipt.py`, который проверяет именно эту сверку. + """ + if not items: + raise ReceiptBuildError("Receipt.Items не может быть пустым") + if len(items) > _MAX_ITEMS: + raise ReceiptBuildError(f"Receipt.Items — не больше {_MAX_ITEMS} позиций") + if taxation not in _ALLOWED_TAXATION: + raise ReceiptBuildError( + f"Taxation={taxation!r} не входит в допустимый список ({sorted(_ALLOWED_TAXATION)})" + ) + + email_norm = (email or "").strip() or None + phone_norm = (phone or "").strip() or None + if not email_norm and not phone_norm: + raise ReceiptBuildError("Нужно указать Email или Phone (хотя бы одно)") + + payload: dict[str, Any] = { + "Taxation": taxation, + "Items": [item.to_payload() for item in items], + } + if email_norm: + payload["Email"] = email_norm + if phone_norm: + payload["Phone"] = phone_norm + return payload + + +def receipt_total_kopecks(receipt: dict[str, Any]) -> int: + """Сумма `Items[].Amount` — для сверки вызывающей стороной с `Init.Amount`.""" + items = receipt.get("Items") + if not isinstance(items, list): + return 0 + total = 0 + for item in items: + if isinstance(item, dict): + amount = item.get("Amount") + if isinstance(amount, int): + total += amount + return total diff --git a/tradein-mvp/backend/app/services/payments/tbank_client.py b/tradein-mvp/backend/app/services/payments/tbank_client.py new file mode 100644 index 00000000..3ba74395 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/tbank_client.py @@ -0,0 +1,249 @@ +"""httpx-клиент Т-Банк эквайринга (Init/GetState/CheckOrder/Confirm/Cancel). + +Стиль и обработка ошибок — по образцу +`app.services.tgbot.client.TelegramClient`: единственные нужные методы, +не тянем отдельный SDK ради пяти HTTP-вызовов. + +Модуль НЕ импортирует `app.core.config` — все параметры (`terminal_key`, +`password`, `base_url`) передаются в конструктор явно аргументами. +Архитектурное ограничение PR-C (см. `app/services/payments/__init__.py`): +параллельный PR-B вводит эти поля в `config.py`, проводку делает PR-D. + +Docs: https://developer.tbank.ru/eacq/api + +Ретраи: + - Сетевые ошибки (timeout/connect) и HTTP 5xx — экспоненциальный backoff, + capped на `_MAX_BACKOFF_S`. + - Любая 4xx — НЕ ретраится (запрос некорректен / права не те — повтор + транспортного вызова не поможет), сразу `TBankApiError`. + - Бизнес-отказ (HTTP 200, но `Success: false` в теле) — тоже НЕ + ретраится: это содержательный ответ банка, а не сбой транспорта. + +БЕЗОПАСНОСТЬ: `password` и `Token` НИКОГДА не попадают в `logger.*` — +логируем только имя метода, HTTP-статус, `ErrorCode`/`Message`/`Details` +из ответа банка. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import httpx + +from app.services.payments.token import sign + +logger = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT_S = 15.0 +_MAX_BACKOFF_S = 30.0 +_DEFAULT_MAX_RETRIES = 3 + +DEFAULT_BASE_URL = "https://securepay.tinkoff.ru" + + +class TBankApiError(Exception): + """T-Bank Acquiring API ответил ошибкой (HTTP-ошибка или `Success: false`).""" + + def __init__(self, method: str, error_code: str, message: str, details: str = "") -> None: + self.method = method + self.error_code = error_code + self.message = message + self.details = details + text = f"T-Bank API {method} failed: [{error_code}] {message}" + if details: + text += f" — {details}" + super().__init__(text) + + +def _error_from_body(response: httpx.Response) -> tuple[str, str, str]: + """Парсит (ErrorCode, Message, Details) из тела ответа; fallback на HTTP-статус.""" + try: + data = response.json() + except ValueError: + return str(response.status_code), (response.text or "")[:200], "" + if not isinstance(data, dict): + return str(response.status_code), str(data)[:200], "" + error_code = str(data.get("ErrorCode", response.status_code)) + message = str(data.get("Message", "")) + details = str(data.get("Details", "")) + return error_code, message, details + + +class TBankClient: + """Клиент Т-Банк эквайринга на `httpx.AsyncClient`. + + Каждый вызов — отдельное короткоживущее соединение (без общего + connection-pool между вызовами; частота вызовов в checkout-потоке + низкая, держать долгоживущий клиент не нужно — тот же паттерн, что + `TelegramClient`). + """ + + def __init__( + self, + *, + terminal_key: str, + password: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = _DEFAULT_TIMEOUT_S, + ) -> None: + self._terminal_key = terminal_key + self._password = password + self._base = f"{base_url.rstrip('/')}/v2" + self._timeout = timeout + + def _signed_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + """Добавляет `TerminalKey` + `Token`. Сам `password` в тело не уходит.""" + body: dict[str, Any] = {"TerminalKey": self._terminal_key, **payload} + body["Token"] = sign(body, self._password) + return body + + async def _request( + self, + method: str, + payload: dict[str, Any], + *, + max_retries: int = _DEFAULT_MAX_RETRIES, + ) -> dict[str, Any]: + """POST `method` с подписанным JSON-телом. Ретраит network/5xx, иначе raise сразу.""" + body = self._signed_payload(payload) + url = f"{self._base}/{method}" + attempt = 0 + + while True: + attempt += 1 + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.post(url, json=body) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + if attempt > max_retries: + logger.error( + "tbank client: %s — network error после %d попыток: %s", + method, + attempt, + exc, + ) + raise TBankApiError(method, "network_error", str(exc)) from exc + backoff = min(2.0**attempt, _MAX_BACKOFF_S) + logger.warning( + "tbank client: %s — network error (попытка %d/%d) — retry через %.0fs", + method, + attempt, + max_retries, + backoff, + ) + await asyncio.sleep(backoff) + continue + + if response.status_code >= 500: + if attempt > max_retries: + error_code, message, details = _error_from_body(response) + logger.error( + "tbank client: %s — HTTP %d после %d попыток, сдаёмся", + method, + response.status_code, + attempt, + ) + raise TBankApiError(method, error_code, message, details) + backoff = min(2.0**attempt, _MAX_BACKOFF_S) + logger.warning( + "tbank client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs", + method, + response.status_code, + attempt, + max_retries, + backoff, + ) + await asyncio.sleep(backoff) + continue + + if response.status_code >= 400: + # 4xx кроме сетевых сценариев выше — запрос некорректен, повтор не поможет. + error_code, message, details = _error_from_body(response) + raise TBankApiError(method, error_code, message, details) + + try: + data = response.json() + except ValueError as exc: + raise TBankApiError(method, "invalid_json", str(exc)) from exc + + if not isinstance(data, dict): + raise TBankApiError(method, "invalid_response", "тело ответа — не JSON-объект") + + if not data.get("Success"): + error_code = str(data.get("ErrorCode", response.status_code)) + message = str(data.get("Message", "")) + details = str(data.get("Details", "")) + raise TBankApiError(method, error_code, message, details) + + return data + + async def init_payment( + self, + *, + order_id: str, + amount_kopecks: int, + description: str = "", + notification_url: str | None = None, + success_url: str | None = None, + fail_url: str | None = None, + receipt: dict[str, Any] | None = None, + pay_type: str | None = None, + data: dict[str, str] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`.""" + payload: dict[str, Any] = {"OrderId": order_id, "Amount": amount_kopecks} + if description: + payload["Description"] = description + if notification_url: + payload["NotificationURL"] = notification_url + if success_url: + payload["SuccessURL"] = success_url + if fail_url: + payload["FailURL"] = fail_url + if receipt: + payload["Receipt"] = receipt + if pay_type: + payload["PayType"] = pay_type + if data: + payload["DATA"] = data + return await self._request("Init", payload) + + async def get_state(self, *, payment_id: str) -> dict[str, Any]: + """`POST /v2/GetState` — статус платежа по `PaymentId`.""" + return await self._request("GetState", {"PaymentId": payment_id}) + + async def check_order(self, *, order_id: str) -> dict[str, Any]: + """`POST /v2/CheckOrder` — список платежей по `OrderId` (для реконсиляции).""" + return await self._request("CheckOrder", {"OrderId": order_id}) + + async def confirm( + self, + *, + payment_id: str, + amount_kopecks: int | None = None, + receipt: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`).""" + payload: dict[str, Any] = {"PaymentId": payment_id} + if amount_kopecks is not None: + payload["Amount"] = amount_kopecks + if receipt: + payload["Receipt"] = receipt + return await self._request("Confirm", payload) + + async def cancel( + self, + *, + payment_id: str, + amount_kopecks: int | None = None, + receipt: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Cancel` — отмена/возврат (полный, если `amount_kopecks` не передан).""" + payload: dict[str, Any] = {"PaymentId": payment_id} + if amount_kopecks is not None: + payload["Amount"] = amount_kopecks + if receipt: + payload["Receipt"] = receipt + return await self._request("Cancel", payload) diff --git a/tradein-mvp/backend/app/services/payments/token.py b/tradein-mvp/backend/app/services/payments/token.py new file mode 100644 index 00000000..80f68a5b --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/token.py @@ -0,0 +1,98 @@ +"""Подпись `Token` запросов Т-Банк эквайринга и проверка подписи нотификаций. + +Docs (проверено живым запросом к doc-порталу, 2026-08-06): +- https://developer.tbank.ru/eacq/intro/developer/token — формирование Token. +- https://developer.tbank.ru/eacq/intro/developer/notification + (раздел «Проверить токен уведомлений») — тот же алгоритм для входящих + нотификаций. + +Алгоритм (идентичен для исходящего запроса и для проверки нотификации): + +1. Берём ТОЛЬКО плоские поля payload: исключаем ключ `Token`, исключаем + `None`, исключаем значения-`dict`/`list` (документация формулирует это + как «кроме параметра Token и вложенных объектов (Data, Receipt)» — + здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые + вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items` + или `Shops`, отсекаются одинаково, потому что все они не примитивы). +2. `bool` → `"true"`/`"false"` (нижний регистр); `int`/`float` → строка без + экспоненциальной записи; `str` — как есть. +3. Добавляем пару `Password: <пароль_терминала>`. +4. Сортируем пары по имени ключа (лексикографически по строке ключа), + конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку. +5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре. + +Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с +doc-портала — оба подтверждены живым запросом, не выдуманы. +""" + +from __future__ import annotations + +import hashlib +import hmac +from typing import Any + +_EXCLUDED_KEYS = frozenset({"Token"}) + + +def _stringify_value(value: bool | int | float | str) -> str: + """Приводит плоское значение к строке по правилам Т-Банка. + + `bool` проверяем ДО `int`: в Python `bool` — подкласс `int` + (`isinstance(True, int) is True`), поэтому порядок веток важен — + иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`. + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + # `format(..., "f")` — фиксированная нотация, Python никогда не + # добавляет экспоненту при presentation type 'f' (в отличие от + # str()/repr(), которые для очень больших/малых float дают "1e+21"). + text = format(value, "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text + return str(value) + + +def _flatten_signable_fields(payload: dict[str, Any]) -> dict[str, str]: + """Плоские поля payload, готовые к конкатенации: без Token/None/dict/list.""" + result: dict[str, str] = {} + for key, value in payload.items(): + if key in _EXCLUDED_KEYS or value is None: + continue + if isinstance(value, dict | list): + continue + result[key] = _stringify_value(value) + return result + + +def sign(payload: dict[str, Any], password: str) -> str: + """Считает `Token` для исходящего запроса (Init/GetState/CheckOrder/...). + + `payload` — тело запроса ДО добавления поля `Token` (поле `Password` + самому передавать не нужно — функция добавляет его сама и удаляет + участие любых вложенных объектов автоматически). + """ + fields = _flatten_signable_fields(payload) + fields["Password"] = password + raw = "".join(fields[key] for key in sorted(fields)) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def verify_notification_token(payload: dict[str, Any], password: str) -> bool: + """Проверяет `Token` входящей нотификации: пересчёт + `hmac.compare_digest`. + + `payload` — полное тело нотификации, включая присланный `Token` (сам + алгоритм сборки исключает ключ `Token` из подписи — см. `_EXCLUDED_KEYS`). + + Возвращает `False`, если в payload нет строкового непустого `Token` + (нечего сравнивать) — вызывающая сторона обязана трактовать это как + отказ в обработке нотификации, а не как «пропустить проверку». + """ + received_token = payload.get("Token") + if not isinstance(received_token, str) or not received_token: + return False + expected_token = sign(payload, password) + return hmac.compare_digest(expected_token, received_token) diff --git a/tradein-mvp/backend/tests/services/payments/__init__.py b/tradein-mvp/backend/tests/services/payments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/tests/services/payments/test_tbank_client.py b/tradein-mvp/backend/tests/services/payments/test_tbank_client.py new file mode 100644 index 00000000..da052625 --- /dev/null +++ b/tradein-mvp/backend/tests/services/payments/test_tbank_client.py @@ -0,0 +1,293 @@ +"""Unit-тесты `app.services.payments.tbank_client.TBankClient`. + +NEVER calls real T-Bank API — только `httpx.MockTransport` (тот же паттерн, +что `tests/services/tgbot/test_client.py` и `tests/services/test_dadata.py`). +`asyncio.sleep` патчится no-op'ом, чтобы retry-тесты шли мгновенно +независимо от реального backoff. +""" + +from __future__ import annotations + +from collections.abc import Callable +from unittest import mock + +import httpx +import pytest + +from app.services.payments.tbank_client import TBankApiError, TBankClient + +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +def _install_transport(handler: Callable[[httpx.Request], httpx.Response]) -> None: + transport = httpx.MockTransport(handler) + + def factory(*_: object, **__: object) -> httpx.AsyncClient: + return _REAL_ASYNC_CLIENT(transport=transport) + + mock.patch("app.services.payments.tbank_client.httpx.AsyncClient", factory).start() + + +@pytest.fixture(autouse=True) +def _stop_patches_and_noop_sleep(): + sleep_patcher = mock.patch( + "app.services.payments.tbank_client.asyncio.sleep", return_value=None + ) + sleep_patcher.start() + yield + mock.patch.stopall() + + +def _client(**kwargs: object) -> TBankClient: + defaults: dict[str, object] = { + "terminal_key": "MerchantTerminalKey", + "password": "test-password", + "base_url": "https://rest-api-test.tinkoff.ru", + } + defaults.update(kwargs) + return TBankClient(**defaults) # type: ignore[arg-type] + + +# ── happy path ──────────────────────────────────────────────────────────────── + + +async def test_init_payment_happy_path_returns_payment_url() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/Init") + return httpx.Response( + 200, + json={ + "Success": True, + "TerminalKey": "MerchantTerminalKey", + "Status": "NEW", + "PaymentId": "12345", + "OrderId": "order-1", + "Amount": 10000, + "PaymentURL": "https://securepay.tinkoff.ru/abc", + }, + ) + + _install_transport(handler) + client = _client() + result = await client.init_payment(order_id="order-1", amount_kopecks=10000) + + assert result["PaymentId"] == "12345" + assert result["PaymentURL"] == "https://securepay.tinkoff.ru/abc" + + +async def test_init_payment_signs_request_with_token() -> None: + """Запрос обязан содержать TerminalKey + Token в теле.""" + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + import json + + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client() + await client.init_payment(order_id="00000", amount_kopecks=19200) + + body = captured["body"] + assert isinstance(body, dict) + assert body["TerminalKey"] == "MerchantTerminalKey" + assert isinstance(body.get("Token"), str) and len(body["Token"]) == 64 + + +async def test_init_payment_receipt_excluded_from_signed_token_but_present_in_body() -> None: + """`Receipt` уходит в тело запроса, но не участвует в Token (см. token.py).""" + import json + + from app.services.payments.token import sign + + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client() + receipt = {"Email": "a@test.ru", "Taxation": "osn", "Items": []} + await client.init_payment(order_id="00000", amount_kopecks=19200, receipt=receipt) + + body = captured["body"] + assert isinstance(body, dict) + assert body["Receipt"] == receipt + + # Token, реально ушедший в теле, обязан совпадать с sign() тела БЕЗ Receipt + # (Receipt — dict, sign() сам его игнорирует) — пересчитаем и сверим. + without_token = {k: v for k, v in body.items() if k != "Token"} + assert body["Token"] == sign(without_token, "test-password") + + +async def test_get_state_posts_to_correct_path() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/GetState") + return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"}) + + _install_transport(handler) + client = _client() + result = await client.get_state(payment_id="12345") + assert result["Status"] == "CONFIRMED" + + +async def test_check_order_posts_to_correct_path() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/CheckOrder") + return httpx.Response(200, json={"Success": True, "Payments": []}) + + _install_transport(handler) + client = _client() + result = await client.check_order(order_id="order-1") + assert result["Payments"] == [] + + +async def test_confirm_posts_to_correct_path() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/Confirm") + return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"}) + + _install_transport(handler) + client = _client() + result = await client.confirm(payment_id="12345") + assert result["Status"] == "CONFIRMED" + + +async def test_cancel_posts_to_correct_path() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/Cancel") + return httpx.Response(200, json={"Success": True, "Status": "REFUNDED"}) + + _install_transport(handler) + client = _client() + result = await client.cancel(payment_id="12345", amount_kopecks=5000) + assert result["Status"] == "REFUNDED" + + +# ── retry policy ───────────────────────────────────────────────────────────── + + +async def test_retries_on_5xx_then_succeeds() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] < 3: + return httpx.Response(502, json={"ErrorCode": "502", "Message": "bad gw"}) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client() + result = await client.init_payment(order_id="1", amount_kopecks=100) + + assert result["PaymentId"] == "1" + assert calls["n"] == 3 + + +async def test_retries_on_network_error_then_succeeds() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] < 2: + raise httpx.ConnectError("connection refused", request=request) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client() + result = await client.init_payment(order_id="1", amount_kopecks=100) + + assert result["PaymentId"] == "1" + assert calls["n"] == 2 + + +async def test_gives_up_after_max_retries_on_persistent_5xx() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"ErrorCode": "500", "Message": "boom"}) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.get_state(payment_id="1") + + assert exc_info.value.error_code == "500" + + +async def test_does_not_retry_on_4xx() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(401, json={"ErrorCode": "401", "Message": "Terminal not found"}) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.init_payment(order_id="1", amount_kopecks=100) + + assert exc_info.value.error_code == "401" + assert calls["n"] == 1 # НЕ ретраится + + +async def test_business_failure_success_false_raises_without_retry() -> None: + """HTTP 200, но `Success: false` — бизнес-отказ банка, не сбой транспорта.""" + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response( + 200, + json={ + "Success": False, + "ErrorCode": "9999", + "Message": "Неверные параметры запроса", + "Details": "Amount must be positive", + }, + ) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.init_payment(order_id="1", amount_kopecks=100) + + assert exc_info.value.error_code == "9999" + assert exc_info.value.message == "Неверные параметры запроса" + assert calls["n"] == 1 # НЕ ретраится + + +async def test_malformed_json_response_raises_tbank_api_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"not json at all") + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError): + await client.get_state(payment_id="1") + + +# ── безопасность: пароль не попадает в тело запроса ───────────────────────── + + +async def test_password_never_sent_in_request_body() -> None: + import json + + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client(password="super-secret-password") + await client.init_payment(order_id="1", amount_kopecks=100) + + body = captured["body"] + assert isinstance(body, dict) + assert "Password" not in body + assert "super-secret-password" not in json.dumps(body) diff --git a/tradein-mvp/backend/tests/test_payments_receipt.py b/tradein-mvp/backend/tests/test_payments_receipt.py new file mode 100644 index 00000000..564e6326 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_receipt.py @@ -0,0 +1,208 @@ +"""Тесты `app.services.payments.receipt` — сборка Receipt (ФФД 1.05, услуга).""" + +from __future__ import annotations + +import pytest + +from app.services.payments.receipt import ( + ReceiptBuildError, + ReceiptItem, + build_receipt, + receipt_total_kopecks, +) + + +def _item(price_kopecks: int = 99000, quantity: int = 1, tax: str = "none") -> ReceiptItem: + return ReceiptItem( + name="Отчёт об оценке квартиры (электронный)", + price_kopecks=price_kopecks, + quantity=quantity, + tax=tax, # type: ignore[arg-type] + ) + + +# ── happy path ──────────────────────────────────────────────────────────────── + + +def test_build_receipt_happy_path_with_email() -> None: + receipt = build_receipt(items=[_item()], taxation="usn_income", email="buyer@example.com") + + assert receipt["Taxation"] == "usn_income" + assert receipt["Email"] == "buyer@example.com" + assert "Phone" not in receipt + assert len(receipt["Items"]) == 1 + + item = receipt["Items"][0] + assert item["Name"] == "Отчёт об оценке квартиры (электронный)" + assert item["Price"] == 99000 + assert item["Quantity"] == 1 + assert item["Amount"] == 99000 + assert item["Tax"] == "none" + assert item["PaymentMethod"] == "full_payment" + assert item["PaymentObject"] == "service" + + +def test_build_receipt_happy_path_with_phone_only() -> None: + receipt = build_receipt(items=[_item()], taxation="osn", phone="+79990000000") + assert receipt["Phone"] == "+79990000000" + assert "Email" not in receipt + + +def test_build_receipt_accepts_both_email_and_phone() -> None: + receipt = build_receipt( + items=[_item()], taxation="osn", email="buyer@example.com", phone="+79990000000" + ) + assert receipt["Email"] == "buyer@example.com" + assert receipt["Phone"] == "+79990000000" + + +def test_amount_is_price_times_quantity() -> None: + item = _item(price_kopecks=10000, quantity=3) + assert item.amount_kopecks == 30000 + payload = item.to_payload() + assert payload["Amount"] == 30000 + + +# ── Email/Phone обязательность ────────────────────────────────────────────── + + +def test_build_receipt_requires_email_or_phone() -> None: + with pytest.raises(ReceiptBuildError, match=r"Email.*Phone|Phone.*Email"): + build_receipt(items=[_item()], taxation="osn") + + +def test_build_receipt_rejects_blank_email_and_phone() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt(items=[_item()], taxation="osn", email=" ", phone="") + + +# ── Items[].Name длина ─────────────────────────────────────────────────────── + + +def test_item_name_exactly_128_chars_is_ok() -> None: + item = ReceiptItem(name="A" * 128, price_kopecks=1000) + payload = item.to_payload() + assert payload["Name"] == "A" * 128 + + +def test_item_name_over_128_chars_rejected() -> None: + item = ReceiptItem(name="A" * 129, price_kopecks=1000) + with pytest.raises(ReceiptBuildError, match="128"): + item.to_payload() + + +def test_item_name_empty_rejected() -> None: + item = ReceiptItem(name="", price_kopecks=1000) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +# ── Price / Quantity валидация ────────────────────────────────────────────── + + +def test_item_zero_price_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=0) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +def test_item_negative_price_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=-100) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +def test_item_zero_quantity_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=100, quantity=0) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +# ── Taxation / Tax enum ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("taxation", ["osn", "usn_income", "usn_income_outcome", "esn", "patent"]) +def test_all_documented_taxation_values_accepted(taxation: str) -> None: + build_receipt(items=[_item()], taxation=taxation, email="a@test.ru") # type: ignore[arg-type] + + +def test_unknown_taxation_rejected() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt( + items=[_item()], + taxation="usn", + email="a@test.ru", # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "tax_rate", + ["none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"], +) +def test_all_documented_2026_tax_rates_accepted(tax_rate: str) -> None: + """Актуальный список 2026 года — все значения проходят валидацию.""" + item = _item(tax=tax_rate) + payload = item.to_payload() + assert payload["Tax"] == tax_rate + + +@pytest.mark.parametrize("removed_rate", ["vat20", "vat120"]) +def test_removed_vat20_vat120_rates_rejected(removed_rate: str) -> None: + """vat20/vat120 сняты с актуального списка 2026 — не должны проходить.""" + item = _item(tax=removed_rate) + with pytest.raises(ReceiptBuildError, match="vat20/vat120"): + item.to_payload() + + +# ── Items[] границы ────────────────────────────────────────────────────────── + + +def test_build_receipt_rejects_empty_items() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt(items=[], taxation="osn", email="a@test.ru") + + +def test_build_receipt_rejects_more_than_100_items() -> None: + items = [_item() for _ in range(101)] + with pytest.raises(ReceiptBuildError, match="100"): + build_receipt(items=items, taxation="osn", email="a@test.ru") + + +def test_build_receipt_accepts_exactly_100_items() -> None: + items = [_item() for _ in range(100)] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + assert len(receipt["Items"]) == 100 + + +# ── инвариант: сумма Items[].Amount == сумме заказа ───────────────────────── + + +def test_receipt_total_matches_order_amount_invariant() -> None: + """Ключевой инвариант задачи: сумма Items[].Amount == общей сумме заказа.""" + order_amount_kopecks = 148500 + items = [ + ReceiptItem(name="Отчёт об оценке", price_kopecks=99000, quantity=1, tax="none"), + ReceiptItem(name="Персональный оффер", price_kopecks=49500, quantity=1, tax="none"), + ] + receipt = build_receipt(items=items, taxation="usn_income", email="a@test.ru") + + assert receipt_total_kopecks(receipt) == order_amount_kopecks + + +def test_receipt_total_multi_quantity_item() -> None: + items = [ReceiptItem(name="Оценка", price_kopecks=5000, quantity=4, tax="vat22")] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + assert receipt_total_kopecks(receipt) == 20000 + + +def test_receipt_total_kopecks_empty_items_key_returns_zero() -> None: + assert receipt_total_kopecks({"Taxation": "osn"}) == 0 + + +def test_receipt_total_mismatch_detected_by_caller() -> None: + """Демонстрирует, как вызывающая сторона обязана сверять сумму с Init.Amount.""" + items = [ReceiptItem(name="Оценка", price_kopecks=10000, quantity=1, tax="none")] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + + wrong_init_amount_kopecks = 99999 + assert receipt_total_kopecks(receipt) != wrong_init_amount_kopecks diff --git a/tradein-mvp/backend/tests/test_payments_token.py b/tradein-mvp/backend/tests/test_payments_token.py new file mode 100644 index 00000000..7a0ce058 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_token.py @@ -0,0 +1,198 @@ +"""Тесты `app.services.payments.token` — подпись Token + проверка нотификаций. + +Эталонные векторы (`test_sign_matches_official_init_vector`, +`test_sign_matches_official_notification_vector`) сняты ДОСЛОВНО живым +запросом (curl, 2026-08-06) с официального doc-портала Т-Банка: + +- Init: https://developer.tbank.ru/eacq/intro/developer/token + (раздел «Сформировать токен») +- Нотификация: https://developer.tbank.ru/eacq/intro/developer/notification + (раздел «Проверить токен уведомлений») + +Оба payload'а и оба итоговых hex-digest скопированы из HTML doc-портала +(не выдуманы) — см. промежуточные шаги в комментариях у каждого теста. +Если хеш перестанет сходиться — чинить `token.py`, НЕ тест. +""" + +from __future__ import annotations + +import hashlib + +from app.services.payments.token import sign, verify_notification_token + +# ── эталонный вектор №1: Init ──────────────────────────────────────────────── +# Doc-портал, шаг за шагом (см. `token.py` docstring для полного описания): +# 1) [{"TerminalKey": "MerchantTerminalKey"}, {"Amount": 19200}, +# {"OrderId": "00000"}, {"Description": "Подарочная карта на 1000 рублей"}] +# 2) + {"Password": "11111111111111"} +# 3) отсортировано по ключу: Amount, Description, OrderId, Password, TerminalKey +# 4) конкатенация значений: +# "19200Подарочная карта на 1000 рублей0000011111111111111MerchantTerminalKey" +# 5) SHA-256 → "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2" + +_INIT_VECTOR_PAYLOAD = { + "TerminalKey": "MerchantTerminalKey", + "Amount": 19200, + "OrderId": "00000", + "Description": "Подарочная карта на 1000 рублей", +} +_INIT_VECTOR_PASSWORD = "11111111111111" +_INIT_VECTOR_TOKEN = "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2" + + +def test_sign_matches_official_init_vector() -> None: + """Официальный вектор Init из документации Т-Банка.""" + assert sign(_INIT_VECTOR_PAYLOAD, _INIT_VECTOR_PASSWORD) == _INIT_VECTOR_TOKEN + + +# ── эталонный вектор №2: нотификация ───────────────────────────────────────── +# Doc-портал, шаг за шагом: +# 1) [{"TerminalKey": "1234567890DEMO"}, {"OrderId": "000000"}, +# {"Success": true}, {"Status": "AUTHORIZED"}, {"PaymentId": "0000000"}, +# {"ErrorCode": "0"}, {"Amount": 1111}, {"CardId": "000000"}, +# {"Pan": "200000******0000"}, {"ExpDate": "1111"}, {"RebillId": "000000"}] +# 2) + {"Password": "11111111111"} +# 3) отсортировано: Amount, CardId, ErrorCode, ExpDate, OrderId, Pan, +# Password, PaymentId, RebillId, Status, Success, TerminalKey +# 4) конкатенация значений: +# "111100000001111000000200000******0000111111111110000000000000AUTHORIZEDtrue1234567890DEMO" +# 5) SHA-256 → "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8" + +_NOTIFICATION_VECTOR_PAYLOAD = { + "TerminalKey": "1234567890DEMO", + "OrderId": "000000", + "Success": True, + "Status": "AUTHORIZED", + "PaymentId": "0000000", + "ErrorCode": "0", + "Amount": 1111, + "CardId": "000000", + "Pan": "200000******0000", + "ExpDate": "1111", + "RebillId": "000000", +} +_NOTIFICATION_VECTOR_PASSWORD = "11111111111" +_NOTIFICATION_VECTOR_TOKEN = "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8" + + +def test_sign_matches_official_notification_vector() -> None: + """Официальный вектор нотификации (Success/AUTHORIZED) из документации Т-Банка.""" + assert ( + sign(_NOTIFICATION_VECTOR_PAYLOAD, _NOTIFICATION_VECTOR_PASSWORD) + == _NOTIFICATION_VECTOR_TOKEN + ) + + +def test_verify_notification_token_accepts_valid_official_vector() -> None: + """`verify_notification_token` — тот же вектор, но с полем Token внутри payload.""" + payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN} + assert verify_notification_token(payload_with_token, _NOTIFICATION_VECTOR_PASSWORD) is True + + +def test_verify_notification_token_rejects_tampered_field() -> None: + """Изменили Amount после подписи → Token больше не совпадает → False.""" + tampered = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN, "Amount": 9999} + assert verify_notification_token(tampered, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_rejects_wrong_password() -> None: + payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN} + assert verify_notification_token(payload_with_token, "wrong-password") is False + + +def test_verify_notification_token_rejects_missing_token() -> None: + assert verify_notification_token(dict(_NOTIFICATION_VECTOR_PAYLOAD), "any-password") is False + + +def test_verify_notification_token_rejects_empty_token() -> None: + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": ""} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +# ── unit-детали алгоритма ───────────────────────────────────────────────────── + + +def test_bool_true_becomes_lowercase_string() -> None: + """`Success: True` (Python bool) → строка "true" в конкатенации.""" + with_bool = sign({"A": True}, "pw") + with_string = sign({"A": "true"}, "pw") + assert with_bool == with_string + + +def test_bool_false_becomes_lowercase_string() -> None: + with_bool = sign({"A": False}, "pw") + with_string = sign({"A": "false"}, "pw") + assert with_bool == with_string + + +def test_int_amount_stringified_without_quotes_semantics() -> None: + """`Amount: 1111` (int) даёт тот же результат, что и `Amount: "1111"` (str).""" + with_int = sign({"Amount": 1111}, "pw") + with_str = sign({"Amount": "1111"}, "pw") + assert with_int == with_str + + +def test_float_without_leading_zero_loss_and_no_exponent() -> None: + """Дробное число сериализуется без экспоненты и без хвостовых нулей. + + Ключи после добавления Password: "A" < "Password" (лексикографически), + поэтому конкатенация — значение A, затем значение Password. + """ + raw = "1234.5" + "pw" + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert sign({"A": 1234.5}, "pw") == expected + + +def test_large_float_has_no_exponential_notation() -> None: + """Очень большое число не сваливается в экспоненциальную запись (`1e+21`).""" + raw = "1000000000000000000000" + "pw" + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert sign({"A": 1e21}, "pw") == expected + + +def test_none_values_are_skipped() -> None: + """`None`-поля не участвуют в конкатенации вообще (не как пустая строка).""" + with_none = sign({"A": "x", "B": None}, "pw") + without_key = sign({"A": "x"}, "pw") + assert with_none == without_key + + +def test_nested_receipt_dict_is_ignored() -> None: + """Вложенный `Receipt` (dict) не участвует в подписи.""" + without_receipt = sign({"A": "x"}, "pw") + with_receipt = sign({"A": "x", "Receipt": {"Email": "a@test.ru", "Items": []}}, "pw") + assert without_receipt == with_receipt + + +def test_nested_data_dict_is_ignored() -> None: + """Вложенный `DATA`/`Data` (dict) не участвует в подписи — оба варианта регистра ключа.""" + baseline = sign({"A": "x"}, "pw") + assert sign({"A": "x", "DATA": {"Phone": "+70000000000"}}, "pw") == baseline + assert sign({"A": "x", "Data": {"Phone": "+70000000000"}}, "pw") == baseline + + +def test_nested_list_items_is_ignored() -> None: + """Вложенный список (`Items`/`Shops` как root-ключ) не участвует в подписи.""" + baseline = sign({"A": "x"}, "pw") + assert sign({"A": "x", "Items": [{"Name": "тест"}]}, "pw") == baseline + assert sign({"A": "x", "Shops": [{"ShopCode": "1"}]}, "pw") == baseline + + +def test_existing_token_field_in_payload_is_excluded() -> None: + """Если в payload уже есть `Token` (например, переподписываем нотификацию) — игнорируется.""" + without_token = sign({"A": "x"}, "pw") + with_token = sign({"A": "x", "Token": "stale-value-from-previous-signing"}, "pw") + assert without_token == with_token + + +def test_sort_is_by_key_name_not_insertion_order() -> None: + """Порядок вставки ключей в payload не влияет на результат — сортировка по ключу.""" + forward = sign({"Zeta": "1", "Alpha": "2", "Mid": "3"}, "pw") + reversed_order = sign({"Mid": "3", "Alpha": "2", "Zeta": "1"}, "pw") + assert forward == reversed_order + + # sanity: строка действительно собрана в алфавитном порядке ключей. + # Ключи с Password: Alpha < Mid < Password < Zeta (лексикографически). + raw = "".join(["2", "3", "pw", "1"]) # Alpha->2, Mid->3, Password->pw, Zeta->1 + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert forward == expected From 00d1f78668d886aeb43c2cbe30df9c6f7c17574a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 15:48:57 +0300 Subject: [PATCH 054/130] =?UTF-8?q?fix(tradein/payments):=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=B8=D0=B9=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BD=D0=BE=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=B4=D0=BE=D0=B3=D0=B0=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BD=D0=B0=20=D0=B2=D1=80=D0=B0=D0=B6=D0=B4?= =?UTF-8?q?=D0=B5=D0=B1=D0=BD=D0=BE=D0=BC=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/payments/__init__.py | 5 +- .../app/services/payments/notification.py | 116 +++++++++++++++ .../app/services/payments/tbank_client.py | 34 ++++- .../backend/app/services/payments/token.py | 69 +++++++-- .../services/payments/test_tbank_client.py | 130 +++++++++++++++++ .../tests/test_payments_notification.py | 136 ++++++++++++++++++ .../backend/tests/test_payments_token.py | 84 +++++++++-- 7 files changed, 545 insertions(+), 29 deletions(-) create mode 100644 tradein-mvp/backend/app/services/payments/notification.py create mode 100644 tradein-mvp/backend/tests/test_payments_notification.py diff --git a/tradein-mvp/backend/app/services/payments/__init__.py b/tradein-mvp/backend/app/services/payments/__init__.py index 1f537849..6a78136f 100644 --- a/tradein-mvp/backend/app/services/payments/__init__.py +++ b/tradein-mvp/backend/app/services/payments/__init__.py @@ -7,7 +7,10 @@ делает следующий PR-D. См. `mera-tbank-acquiring-recon.md` (корень репо) §3/§9 для полной схемы разбивки. -- `token.py` — подпись `Token` запросов + проверка подписи нотификаций. +- `token.py` — подпись `Token` запросов + проверка подписи нотификаций + (никогда не кидает исключение на враждебном входе). +- `notification.py` — строгий типизированный разбор тела нотификации ПОСЛЕ + проверки подписи (`parse_notification`) — сырой `dict` дальше не уходит. - `receipt.py` — сборка `Receipt` (54-ФЗ, ФФД 1.05) для услуги. - `tbank_client.py` — httpx-клиент `Init/GetState/CheckOrder/Confirm/Cancel`. diff --git a/tradein-mvp/backend/app/services/payments/notification.py b/tradein-mvp/backend/app/services/payments/notification.py new file mode 100644 index 00000000..48faacac --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/notification.py @@ -0,0 +1,116 @@ +"""Строгий типизированный разбор нотификации Т-Банк — ПОСЛЕ проверки подписи. + +Вызывать `parse_notification()` только когда `token.verify_notification_token(...)` +уже вернул `True`. Разбор здесь НЕ проверяет подпись повторно — он только +превращает уже доверенный (по подписи) `dict` в типизированный объект, чтобы +сырой `dict` не утекал дальше в бизнес-логику (статус-машину платежа, запись +в БД). + +ПОЧЕМУ строгий разбор — самостоятельный слой защиты, а не формальность: +алгоритм подписи Т-Банка конкатенирует значения полей БЕЗ разделителя между +ними (см. `token.py`, docstring модуля, шаг 4). Из-за этого символы могут +"перекладываться" между лексикографически соседними ключами так, что итоговая +строка для SHA-256 не меняется, хотя значения полей — меняются. Проверено +живым расчётом на официальном эталонном векторе: `Amount=1111, CardId="000000"` +даёт тот же Token, что и `Amount=11, CardId="11000000"` (доп. `1` "перетекла" +из `Amount` в начало `CardId`, потому что `Amount` < `CardId` лексикографически +и обе стоят подряд в конкатенации). Значит подпись сама по себе НЕ гарантирует, +что банк прислал именно ту сумму, которую записал у себя платёжный сервис — +это СВОЙСТВО алгоритма банка, менять его нельзя (мы не управляем форматом +Token, который реально пришлёт банк на проде). + +КОНТРАКТ ДЛЯ PR-D (публичная ручка нотификации) — единственная реальная защита +от описанного выше перекладывания: `amount_kopecks` из `parse_notification()` +ОБЯЗАН быть сверен с уже сохранённым `payments.amount_kopecks` в БД (запись, +созданная на `init_payment()`, найденная по `order_id`/`payment_id` из этой же +нотификации) ДО того, как нотификация будет принята как валидное событие. +Если сумма из нотификации не совпадает с суммой в БД — это либо подделанная +нотификация (перекладывание символов дало другой `OrderId`/`Amount`-ключ и +подпись всё равно сошлась), либо рассинхронизация, но НЕ штатный кейс — то и +другое должно быть отказом, а не «примерно похоже, примем». +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +class NotificationParseError(ValueError): + """Поле нотификации не соответствует ожидаемому типу — отказ, не догадка.""" + + +@dataclass(frozen=True, slots=True) +class TBankNotification: + """Типизированное тело нотификации Т-Банка ПОСЛЕ успешной проверки подписи. + + `amount_kopecks` здесь — то, что ПРИСЛАЛ банк в текущем HTTP-запросе, а + НЕ подтверждённый источник истины сам по себе. См. docstring модуля — + сверка с `payments.amount_kopecks` в БД обязательна на вызывающей стороне. + """ + + success: bool + status: str + order_id: str + payment_id: str + terminal_key: str + amount_kopecks: int + + +def parse_notification(payload: dict[str, Any]) -> TBankNotification: + """Строгий разбор `payload` в `TBankNotification`. + + Вызывать ТОЛЬКО после `token.verify_notification_token(payload, password) + is True` — эта функция подпись не проверяет. + + Правила (без исключений, без «примерно разберём»): + - `Success` — только настоящий `bool` (не строка `"true"`, не `1`); + - `Amount` — только `int`; `bool` — подкласс `int` в Python + (`isinstance(True, int) is True`), поэтому проверяется и отсекается + ДО проверки на `int`, иначе `Success`-подобное поле молча прошло бы + как сумма; + - `Status`, `OrderId`, `PaymentId`, `TerminalKey` — только непустой `str`. + + Любое несоответствие — `NotificationParseError` с указанием поля, + ожидаемого типа и того, что реально пришло. + """ + if not isinstance(payload, dict): + raise NotificationParseError(f"payload должен быть dict, получено {type(payload).__name__}") + + return TBankNotification( + success=_require_strict_bool(payload, "Success"), + status=_require_nonempty_str(payload, "Status"), + order_id=_require_nonempty_str(payload, "OrderId"), + payment_id=_require_nonempty_str(payload, "PaymentId"), + terminal_key=_require_nonempty_str(payload, "TerminalKey"), + amount_kopecks=_require_strict_int(payload, "Amount"), + ) + + +def _require_strict_bool(payload: dict[str, Any], key: str) -> bool: + value = payload.get(key) + if not isinstance(value, bool): + raise NotificationParseError( + f"{key} должен быть bool, получено {type(value).__name__}={value!r}" + ) + return value + + +def _require_strict_int(payload: dict[str, Any], key: str) -> int: + value = payload.get(key) + # bool — подкласс int в Python: проверяем и отсекаем ДО isinstance(value, int), + # иначе True/False молча прошли бы как Amount=1/Amount=0. + if isinstance(value, bool) or not isinstance(value, int): + raise NotificationParseError( + f"{key} должен быть int (не bool/str/float), получено {type(value).__name__}={value!r}" + ) + return value + + +def _require_nonempty_str(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value: + raise NotificationParseError( + f"{key} должен быть непустой str, получено {type(value).__name__}={value!r}" + ) + return value diff --git a/tradein-mvp/backend/app/services/payments/tbank_client.py b/tradein-mvp/backend/app/services/payments/tbank_client.py index 3ba74395..e9baa367 100644 --- a/tradein-mvp/backend/app/services/payments/tbank_client.py +++ b/tradein-mvp/backend/app/services/payments/tbank_client.py @@ -19,6 +19,16 @@ Docs: https://developer.tbank.ru/eacq/api - Бизнес-отказ (HTTP 200, но `Success: false` в теле) — тоже НЕ ретраится: это содержательный ответ банка, а не сбой транспорта. +БЮДЖЕТ ВРЕМЕНИ (важно для PR-D): worst case одного вызова любого метода — +около 74 с (4 попытки × `_DEFAULT_TIMEOUT_S`=15 с = 60 с, плюс backoff между +попытками 2+4+8=14 с при `_DEFAULT_MAX_RETRIES`=3). Т-Банк даёт на ответ на +нотификацию окно порядка 10 с — этот бюджет в 74 с в него заведомо не +укладывается. Значит: исходящий HTTP-вызов к `TBankClient` (в т.ч. +`get_state`/`confirm`/`cancel` для сверки/реконсиляции по нотификации) +ВНУТРИ обработчика публичной ручки нотификации ЗАПРЕЩЁН — обработчик обязан +только валидировать/сохранить событие и ответить `"OK"`, а любая сверка с +банком (`GetState`/`CheckOrder`) — асинхронно, вне HTTP-цикла ответа банку. + БЕЗОПАСНОСТЬ: `password` и `Token` НИКОГДА не попадают в `logger.*` — логируем только имя метода, HTTP-статус, `ErrorCode`/`Message`/`Details` из ответа банка. @@ -192,7 +202,17 @@ class TBankClient: pay_type: str | None = None, data: dict[str, str] | None = None, ) -> dict[str, Any]: - """`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`.""" + """`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`. + + КОНТРАКТ ДЛЯ PR-D (обработка сетевой ошибки вызывающей стороной): + после `TBankApiError` от `Init` (в т.ч. `error_code == "network_error"` — + таймаут/обрыв) НЕЛЬЗЯ слепо повторять `init_payment()` с тем же + `order_id` — неизвестно, дошёл ли исходный запрос до банка до обрыва + соединения. Слепой повтор может создать ВТОРОЙ холд на тот же + `OrderId`. Разбираться нужно через `check_order(order_id=...)` — + он возвращает уже существующие платежи по заказу — и только по его + результату решать, нужен ли новый `Init`. + """ payload: dict[str, Any] = {"OrderId": order_id, "Amount": amount_kopecks} if description: payload["Description"] = description @@ -225,7 +245,17 @@ class TBankClient: amount_kopecks: int | None = None, receipt: dict[str, Any] | None = None, ) -> dict[str, Any]: - """`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`).""" + """`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`). + + КОНТРАКТ ДЛЯ PR-D (обработка ошибки вызывающей стороной): после + `TBankApiError` от `Confirm` (в т.ч. сетевой таймаут) слепой вызов + `cancel()` для того же `payment_id` ЗАПРЕЩЁН. Таймаут/обрыв мог + прийти УЖЕ ПОСЛЕ того, как банк фактически подтвердил холд — + `Confirm` состоялся на стороне банка, а ответ до клиента не дошёл. + В этом случае `cancel()` вернёт клиенту уже захваченные деньги. + Правильная последовательность: сначала `get_state(payment_id=...)`, + и только по актуальному статусу решать, нужен ли `cancel()`. + """ payload: dict[str, Any] = {"PaymentId": payment_id} if amount_kopecks is not None: payload["Amount"] = amount_kopecks diff --git a/tradein-mvp/backend/app/services/payments/token.py b/tradein-mvp/backend/app/services/payments/token.py index 80f68a5b..7a74a075 100644 --- a/tradein-mvp/backend/app/services/payments/token.py +++ b/tradein-mvp/backend/app/services/payments/token.py @@ -14,8 +14,9 @@ Docs (проверено живым запросом к doc-порталу, 2026 здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items` или `Shops`, отсекаются одинаково, потому что все они не примитивы). -2. `bool` → `"true"`/`"false"` (нижний регистр); `int`/`float` → строка без - экспоненциальной записи; `str` — как есть. +2. `bool` → `"true"`/`"false"` (нижний регистр); `int` → строка через `str()`; + `str` — как есть. `float` НЕ поддерживается — падаем явной ошибкой (формат + дробных чисел не задокументирован Т-Банком, см. `_stringify_value`). 3. Добавляем пару `Password: <пароль_терминала>`. 4. Сортируем пары по имени ключа (лексикографически по строке ключа), конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку. @@ -29,30 +30,45 @@ from __future__ import annotations import hashlib import hmac +import logging from typing import Any +logger = logging.getLogger(__name__) + _EXCLUDED_KEYS = frozenset({"Token"}) +class TokenSigningError(ValueError): + """Поле не может быть однозначно сериализовано в подписываемую строку.""" + + def _stringify_value(value: bool | int | float | str) -> str: """Приводит плоское значение к строке по правилам Т-Банка. `bool` проверяем ДО `int`: в Python `bool` — подкласс `int` (`isinstance(True, int) is True`), поэтому порядок веток важен — иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`. + + `float` НЕ поддерживается — падаем с `TokenSigningError`, а не + угадываем формат. Документация Т-Банка не описывает сериализацию + дробных чисел в подписи; прежняя реализация (`format(value, "f")` + + rstrip нулей) была неподтверждённой догадкой, и она расходится с тем, + что реально уходит в JSON-теле запроса: `0.1 + 0.2` подписывался бы + как `"0.3"`, а `json.dumps(0.1 + 0.2)` даёт `"0.30000000000000004"` — + Token не соответствовал бы фактическому телу. Денежные суммы (`Amount`) + в этом API всегда целые копейки (`int`); для денежного пути правильнее + явно упасть на нецелом значении, чем подписать не то, что уйдёт в сеть. """ if isinstance(value, bool): return "true" if value else "false" if isinstance(value, int): return str(value) if isinstance(value, float): - # `format(..., "f")` — фиксированная нотация, Python никогда не - # добавляет экспоненту при presentation type 'f' (в отличие от - # str()/repr(), которые для очень больших/малых float дают "1e+21"). - text = format(value, "f") - if "." in text: - text = text.rstrip("0").rstrip(".") - return text + raise TokenSigningError( + f"float в подписываемых полях не поддерживается (получено {value!r}) — " + "формат дробных чисел не описан в документации Т-Банка, см. docstring " + "_stringify_value" + ) return str(value) @@ -90,9 +106,36 @@ def verify_notification_token(payload: dict[str, Any], password: str) -> bool: Возвращает `False`, если в payload нет строкового непустого `Token` (нечего сравнивать) — вызывающая сторона обязана трактовать это как отказ в обработке нотификации, а не как «пропустить проверку». + + НИКОГДА не поднимает исключение — на любом враждебном/мусорном входе + (не `dict`, не-ASCII `Token`, поля, которые ломают сериализацию внутри + `sign()`) возвращает `False`. Это обязательное свойство для публичной + ручки нотификации (PR-D): необработанное исключение здесь — это + неаутентифицированный HTTP 500 в ответ банку, а любой ответ, отличный + от `"OK"`, банк трактует как временный сбой и ретраит уведомление + почасово в течение суток. Конкретные причины двух проверок ниже: + - `payload` не `dict` (например список) → `.get()` кинул бы + `AttributeError` без явной проверки типа; + - `Token` с не-ASCII символами → `hmac.compare_digest` на строках + требует ASCII и иначе кидает `TypeError` (документированное + ограничение stdlib, не баг). """ - received_token = payload.get("Token") - if not isinstance(received_token, str) or not received_token: + if not isinstance(payload, dict): + return False + received_token = payload.get("Token") + if not isinstance(received_token, str) or not received_token or not received_token.isascii(): + return False + try: + expected_token = sign(payload, password) + except Exception: + # Мусорное поле где-то ещё в payload (например float — см. + # `_stringify_value`) не должно валить проверку подписи в исключение. + logger.warning( + "verify_notification_token: sign() упал на входящем payload — трактуем как отказ", + exc_info=True, + ) + return False + try: + return hmac.compare_digest(expected_token, received_token) + except TypeError: return False - expected_token = sign(payload, password) - return hmac.compare_digest(expected_token, received_token) diff --git a/tradein-mvp/backend/tests/services/payments/test_tbank_client.py b/tradein-mvp/backend/tests/services/payments/test_tbank_client.py index da052625..f7fbf390 100644 --- a/tradein-mvp/backend/tests/services/payments/test_tbank_client.py +++ b/tradein-mvp/backend/tests/services/payments/test_tbank_client.py @@ -260,6 +260,136 @@ async def test_business_failure_success_false_raises_without_retry() -> None: assert calls["n"] == 1 # НЕ ретраится +async def test_confirm_retries_on_5xx_then_succeeds() -> None: + """Денежный вызов `Confirm` ретраится на 5xx так же, как `Init`/`GetState`.""" + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] < 3: + return httpx.Response(502, json={"ErrorCode": "502", "Message": "bad gw"}) + return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"}) + + _install_transport(handler) + client = _client() + result = await client.confirm(payment_id="1") + + assert result["Status"] == "CONFIRMED" + assert calls["n"] == 3 + + +async def test_confirm_retries_on_network_error_then_succeeds() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] < 2: + raise httpx.ConnectError("connection refused", request=request) + return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"}) + + _install_transport(handler) + client = _client() + result = await client.confirm(payment_id="1") + + assert result["Status"] == "CONFIRMED" + assert calls["n"] == 2 + + +async def test_confirm_gives_up_after_max_retries_on_persistent_5xx() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"ErrorCode": "500", "Message": "boom"}) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.confirm(payment_id="1") + + assert exc_info.value.error_code == "500" + + +async def test_confirm_does_not_retry_on_4xx() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(401, json={"ErrorCode": "401", "Message": "Terminal not found"}) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.confirm(payment_id="1") + + assert exc_info.value.error_code == "401" + assert calls["n"] == 1 # НЕ ретраится + + +async def test_cancel_retries_on_5xx_then_succeeds() -> None: + """Денежный вызов `Cancel` ретраится на 5xx так же, как `Init`/`GetState`.""" + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] < 3: + return httpx.Response(503, json={"ErrorCode": "503", "Message": "unavailable"}) + return httpx.Response(200, json={"Success": True, "Status": "REFUNDED"}) + + _install_transport(handler) + client = _client() + result = await client.cancel(payment_id="1") + + assert result["Status"] == "REFUNDED" + assert calls["n"] == 3 + + +async def test_cancel_retries_on_network_error_then_succeeds() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] < 2: + raise httpx.ConnectTimeout("timed out", request=request) + return httpx.Response(200, json={"Success": True, "Status": "REFUNDED"}) + + _install_transport(handler) + client = _client() + result = await client.cancel(payment_id="1") + + assert result["Status"] == "REFUNDED" + assert calls["n"] == 2 + + +async def test_cancel_gives_up_after_max_retries_on_persistent_network_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.cancel(payment_id="1") + + assert exc_info.value.error_code == "network_error" + + +async def test_cancel_does_not_retry_on_4xx() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(401, json={"ErrorCode": "401", "Message": "Terminal not found"}) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.cancel(payment_id="1") + + assert exc_info.value.error_code == "401" + assert calls["n"] == 1 # НЕ ретраится + + async def test_malformed_json_response_raises_tbank_api_error() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, content=b"not json at all") diff --git a/tradein-mvp/backend/tests/test_payments_notification.py b/tradein-mvp/backend/tests/test_payments_notification.py new file mode 100644 index 00000000..110c8234 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_notification.py @@ -0,0 +1,136 @@ +"""Тесты `app.services.payments.notification` — строгий разбор нотификации. + +`parse_notification()` вызывается ПОСЛЕ `verify_notification_token(...) is True` +(эта функция подпись не проверяет) — тесты здесь работают с payload напрямую, +без пересчёта подписи. +""" + +from __future__ import annotations + +import pytest + +from app.services.payments.notification import ( + NotificationParseError, + TBankNotification, + parse_notification, +) + +_VALID_PAYLOAD = { + "TerminalKey": "1234567890DEMO", + "OrderId": "order-1", + "Success": True, + "Status": "CONFIRMED", + "PaymentId": "0000000", + "Amount": 111100, + "Token": "irrelevant-here", +} + + +# ── happy path ──────────────────────────────────────────────────────────────── + + +def test_parse_notification_happy_path_returns_typed_object() -> None: + result = parse_notification(_VALID_PAYLOAD) + + assert isinstance(result, TBankNotification) + assert result.success is True + assert result.status == "CONFIRMED" + assert result.order_id == "order-1" + assert result.payment_id == "0000000" + assert result.terminal_key == "1234567890DEMO" + assert result.amount_kopecks == 111100 + + +def test_parse_notification_ignores_extra_fields() -> None: + """Лишние поля (ErrorCode, CardId, Pan, ...) в payload не мешают разбору.""" + payload = {**_VALID_PAYLOAD, "ErrorCode": "0", "CardId": "000000", "Pan": "200000******0000"} + result = parse_notification(payload) + assert result.order_id == "order-1" + + +# ── payload не dict ───────────────────────────────────────────────────────── + + +def test_parse_notification_rejects_non_dict_payload() -> None: + with pytest.raises(NotificationParseError): + parse_notification([_VALID_PAYLOAD]) # type: ignore[arg-type] + + +# ── Success: только настоящий bool ────────────────────────────────────────── + + +def test_parse_notification_rejects_success_as_string_true() -> None: + payload = {**_VALID_PAYLOAD, "Success": "true"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_success_as_int_one() -> None: + payload = {**_VALID_PAYLOAD, "Success": 1} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_missing_success() -> None: + payload = {k: v for k, v in _VALID_PAYLOAD.items() if k != "Success"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +# ── Amount: только int, bool отдельно отсекается ──────────────────────────── + + +def test_parse_notification_rejects_amount_as_bool_true() -> None: + """`bool` — подкласс `int` в Python, поэтому отсекается ДО общей int-проверки.""" + payload = {**_VALID_PAYLOAD, "Amount": True} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_bool_false() -> None: + payload = {**_VALID_PAYLOAD, "Amount": False} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_float() -> None: + payload = {**_VALID_PAYLOAD, "Amount": 111100.0} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_string() -> None: + payload = {**_VALID_PAYLOAD, "Amount": "111100"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +# ── строковые поля: только непустой str ───────────────────────────────────── + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_empty_string_field(field: str) -> None: + payload = {**_VALID_PAYLOAD, field: ""} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_non_string_field(field: str) -> None: + payload = {**_VALID_PAYLOAD, field: 12345} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_missing_field(field: str) -> None: + payload = {k: v for k, v in _VALID_PAYLOAD.items() if k != field} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_result_is_frozen() -> None: + """`TBankNotification` — frozen dataclass, случайная мутация после разбора невозможна.""" + result = parse_notification(_VALID_PAYLOAD) + with pytest.raises(AttributeError): + result.amount_kopecks = 1 # type: ignore[misc] diff --git a/tradein-mvp/backend/tests/test_payments_token.py b/tradein-mvp/backend/tests/test_payments_token.py index 7a0ce058..23101559 100644 --- a/tradein-mvp/backend/tests/test_payments_token.py +++ b/tradein-mvp/backend/tests/test_payments_token.py @@ -18,7 +18,9 @@ from __future__ import annotations import hashlib -from app.services.payments.token import sign, verify_notification_token +import pytest + +from app.services.payments.token import TokenSigningError, sign, verify_notification_token # ── эталонный вектор №1: Init ──────────────────────────────────────────────── # Doc-портал, шаг за шагом (см. `token.py` docstring для полного описания): @@ -132,22 +134,30 @@ def test_int_amount_stringified_without_quotes_semantics() -> None: assert with_int == with_str -def test_float_without_leading_zero_loss_and_no_exponent() -> None: - """Дробное число сериализуется без экспоненты и без хвостовых нулей. +def test_float_field_raises_instead_of_guessing_format() -> None: + """`float` больше не сериализуется по угадываемому формату — явный отказ. - Ключи после добавления Password: "A" < "Password" (лексикографически), - поэтому конкатенация — значение A, затем значение Password. + Раньше `format(value, "f")` + rstrip нулей выдавал для `0.1 + 0.2` строку + "0.3", а `json.dumps(0.1 + 0.2)` реально даёт "0.30000000000000004" — + подписывалось не то, что уходит в JSON-теле запроса. Для денежного пути + (Amount — всегда int, копейки) правильнее упасть, чем угадать формат. """ - raw = "1234.5" + "pw" - expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() - assert sign({"A": 1234.5}, "pw") == expected + with pytest.raises(TokenSigningError): + sign({"A": 1234.5}, "pw") -def test_large_float_has_no_exponential_notation() -> None: - """Очень большое число не сваливается в экспоненциальную запись (`1e+21`).""" - raw = "1000000000000000000000" + "pw" - expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() - assert sign({"A": 1e21}, "pw") == expected +def test_large_float_field_also_raises() -> None: + """Тот же явный отказ и для значений, которые раньше ушли бы без экспоненты.""" + with pytest.raises(TokenSigningError): + sign({"A": 1e21}, "pw") + + +def test_amount_plus_float_sum_would_have_diverged_from_json_raises() -> None: + """Закрепляет мотивацию отказа: 0.1+0.2 != json.dumps(0.1+0.2) как строка.""" + computed = 0.1 + 0.2 + assert format(computed, "f").rstrip("0").rstrip(".") == "0.3" + with pytest.raises(TokenSigningError): + sign({"Amount": computed}, "pw") def test_none_values_are_skipped() -> None: @@ -196,3 +206,51 @@ def test_sort_is_by_key_name_not_insertion_order() -> None: raw = "".join(["2", "3", "pw", "1"]) # Alpha->2, Mid->3, Password->pw, Zeta->1 expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() assert forward == expected + + +# ── verify_notification_token: НИКОГДА не кидает исключение на мусоре ───────── +# +# После появления публичной ручки нотификации (PR-D) необработанное +# исключение здесь = неаутентифицированный HTTP 500 в ответ банку, а любой +# ответ, отличный от "OK", банк трактует как временный сбой и ретраит +# нотификацию почасово в течение суток — см. docstring +# `verify_notification_token`. + + +def test_verify_notification_token_rejects_non_dict_payload_list() -> None: + """payload — список, не dict → `.get()` кинул бы AttributeError без guard'а.""" + assert verify_notification_token([{"Token": "x"}], "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_dict_payload_string() -> None: + assert verify_notification_token("not-a-dict", "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_dict_payload_none() -> None: + assert verify_notification_token(None, "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_ascii_token() -> None: + """Не-ASCII Token → `hmac.compare_digest` кинул бы TypeError без guard'а.""" + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": "кириллица-не-hex-токен"} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_rejects_non_string_token() -> None: + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": 12345} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_does_not_raise_on_float_field() -> None: + """Поле-float где-то в payload (после отказа sign() от float) → False, не исключение.""" + payload = { + "TerminalKey": "demo", + "OrderId": "1", + "Amount": 11.5, # float — sign() теперь явно падает на нём + "Token": "0" * 64, + } + assert verify_notification_token(payload, "pw") is False + + +def test_verify_notification_token_rejects_empty_dict() -> None: + assert verify_notification_token({}, "pw") is False From 64a79755493f97a77e64190e12e98bbfbb5e4db2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 12:50:24 +0000 Subject: [PATCH 055/130] =?UTF-8?q?fix(tradein/proxy):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=20=D1=83=D0=B7=D0=BB=D0=B0=20=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=20=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=BE=D0=BC?= =?UTF-8?q?,=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D1=91=D1=82=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BE=D1=82=20HTTP-=D0=BF=D1=80=D0=BE=D0=B1=D1=8B=20(?= =?UTF-8?q?#2723)=20(#2736)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/proxy_pool.py | 283 ++++++++++++++- .../sql/228_scrape_proxies_browser_health.sql | 66 ++++ .../backend/tests/services/test_proxy_pool.py | 68 +++- .../backend/tests/test_2723_browser_probe.py | 329 ++++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 109 ++++++ 5 files changed, 844 insertions(+), 11 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql create mode 100644 tradein-mvp/backend/tests/test_2723_browser_probe.py diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 0e7e503d..ed40dc63 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -77,6 +77,20 @@ Sticky session lease (browser-путь, живая регрессия 2026-08): на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового прогона. +Два тракта — два диагноза (#2723): + - ipify-проба (`_probe_proxy`) отвечает на «узел жив вообще» и владеет + consecutive_fails / enabled / exit_ip. Такт — каждый прогон healthcheck (30 мин). + - браузерная проба (`_run_browser_probe` → сайдкар → camoufox с ЭТИМ прокси → + навигация) отвечает на «через узел работает браузерный тракт» и владеет + browser_fail_streak / browser_unfit_since / browser_check_at (миграция 228). + Такт свой, редкий (BROWSER_PROBE_MINUTES) — она стоит запуска camoufox. + Пересечения нет: успешная ipify-проба НЕ обнуляет browser_fail_streak (иначе + дешёвая проба каждые 30 минут стирает вердикт дорогого тракта — узел, мёртвый для + браузера, вечно возвращается в выдачу), провал браузерной пробы НЕ выключает узел + (он жив, просто не для этого тракта). Схлопнуть их в один флаг = повторить #2686. + «Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой + узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -90,9 +104,13 @@ import httpx from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings as _settings + logger = logging.getLogger(__name__) __all__ = [ + "BROWSER_PROBE_MINUTES", + "BROWSER_UNFIT_THRESHOLD", "DISABLED_RECHECK_MINUTES", "DISABLE_THRESHOLD", "MAX_CONSECUTIVE_FAILS", @@ -105,6 +123,7 @@ __all__ = [ "acquire", "clear_source_bans", "mark_banned", + "mark_browser_health", "mark_health", "reap_stale_leases", "release", @@ -155,6 +174,27 @@ SOURCE_BAN_PURGE_DAYS = 7 _HEALTH_PROBE_URL = "https://api.ipify.org" _HEALTH_PROBE_TIMEOUT_S = 10.0 +# ── браузерная проба узла (#2723) ──────────────────────────────────────────── +# Такт браузерной пробы. Решено по замеру, не по ощущению (прод, 06.08.2026): +# - одна браузерная проба = 8.3с и один запуск camoufox; +# - боевая нагрузка сайдкара = ~42 /fetch и ~8 запусков camoufox в час +# (≈1000 и ≈190 в сутки); +# - такт ipify-пробы = 30 мин → 48 прогонов healthcheck в сутки. +# Гнать браузерную пробу каждым прогоном по 4 узлам = +192 запуска camoufox в сутки, +# то есть УДВОЕНИЕ самой дорогой операции сайдкара ради диагностики. 360 мин даёт +# 4 пробы на узел в сутки: +16 запусков (+8% к запускам, +1.6% к запросам) — цена, +# которую видно только в логе. Отказ, пойманный с задержкой до 6 часов, всё равно +# ловится в разы раньше, чем сейчас (не ловится вовсе). +BROWSER_PROBE_MINUTES = 360 + +# Столько подряд-провалов браузерной пробы (атрибутированных узлу) переводят узел в +# browser_unfit. Не 1: запуск camoufox бывает флаки сам по себе, а пометка — операция +# с последствиями при пуле из 4 узлов. Не 5 (как DISABLE_THRESHOLD): при редком такте +# это были бы сутки. Второе подтверждение приходит на СЛЕДУЮЩЕМ прогоне healthcheck +# (~30 мин), а не через полный такт — browser_check_at на неподтверждённом провале +# намеренно не обновляется (см. mark_browser_health). +BROWSER_UNFIT_THRESHOLD = 2 + # deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для # mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что # в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не @@ -213,7 +253,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe db.execute( text( """ - SELECT id, url, kind, rotate_url + SELECT id, url, kind, rotate_url, browser_unfit_since FROM scrape_proxies WHERE enabled AND consecutive_fails < CAST(:max_fails AS integer) @@ -226,7 +266,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe AND b.source = :provider AND b.banned_until > now() ) - ORDER BY last_ok_at NULLS LAST, id + -- browser_unfit последним (#2723): узел, живой для HTTP, но не для + -- браузера, из пула НЕ исключается — только уходит в конец очереди. + ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id FOR UPDATE SKIP LOCKED LIMIT 1 """ @@ -247,7 +289,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe db.execute( text( """ - SELECT sp.id, sp.url, sp.kind, sp.rotate_url + SELECT sp.id, sp.url, sp.kind, sp.rotate_url, sp.browser_unfit_since FROM scrape_proxies AS sp WHERE sp.enabled AND sp.consecutive_fails < CAST(:max_fails AS integer) @@ -283,7 +325,8 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe ) ) ) - ORDER BY sp.last_ok_at NULLS LAST, sp.id + -- см. ORDER BY основного запроса (#2723) + ORDER BY (sp.browser_unfit_since IS NOT NULL), sp.last_ok_at NULLS LAST, sp.id FOR UPDATE SKIP LOCKED LIMIT 1 """ @@ -323,6 +366,19 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe logger.info( "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker ) + if row["browser_unfit_since"] is not None: + # Узел помечен непригодным для браузера (#2723), но всё равно выдан — значит + # пригодных свободных не осталось. Голодание хуже работы через плохой узел + # (та же политика, что у защиты последнего узла в mark_banned), но молчать об + # этом нельзя: для браузерного источника это заведомо обречённый прогон. + logger.warning( + "proxy_pool: leased proxy id=%d provider=%s — узел BROWSER-UNFIT с %s " + "(жив для HTTP, браузерный тракт через него не работает). Выдан потому, " + "что пригодных свободных узлов нет — пул надо пополнять (#2638).", + proxy_id, + provider, + row["browser_unfit_since"], + ) return ProxyLease( id=proxy_id, url=str(row["url"]), @@ -480,6 +536,145 @@ def mark_health( ) +def mark_browser_health( + db: Session, + proxy_id: int, + ok: bool, + *, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать результат БРАУЗЕРНОЙ пробы узла (#2723). Returns исход для счётчиков. + + ЧЕМ ОТЛИЧАЕТСЯ ОТ mark_health: тем же, чем «нас забанила площадка» отличается от + «у нас упал сайдкар» (#2686/#2711) — это ДРУГОЙ диагноз, а не другое значение того + же. mark_health отвечает на «узел жив вообще» и владеет + consecutive_fails/enabled/exit_ip. Эта функция отвечает на «через узел работает + браузерный тракт» и владеет browser_fail_streak/browser_unfit_since/ + browser_check_at. Пересечения нет НИ В ОДНУ сторону, и это главное: + + - успешная ipify-проба НЕ обнуляет browser_fail_streak. До #2723 обнуляла бы + (через consecutive_fails=0) — узел, мёртвый для браузера, выходил из карантина + каждые ≤30 минут и снова забирал прогон; + - провал браузерной пробы НЕ инкрементит consecutive_fails и НЕ выключает узел: + он жив, просто не для этого тракта. + + ЧТО СЧИТАЕТСЯ ПРОВАЛОМ УЗЛА: только fail_kind == "proxy" (см. + scraper_kit.browser_fetcher.classify_browser_probe). "sidecar" (сайдкар лежит) и + "page" (площадка отдала пустое) узлу не принадлежат — засчитывать их значило бы + пометить непригодными ВСЕ узлы разом при одной упавшей общей зависимости, то есть + повторить #2686 ещё раз и уже с последствиями для всего пула. + + ТАКТ ПРИ ПРОВАЛЕ: browser_check_at обновляется только когда провал ПОДТВЕРЖДЁН + (streak дошёл до BROWSER_UNFIT_THRESHOLD). На первом, ещё не подтверждённом + провале поле остаётся старым → следующий же прогон healthcheck (~30 мин) повторит + пробу и либо подтвердит отказ, либо снимет подозрение. Иначе подтверждения ждали бы + полный BROWSER_PROBE_MINUTES. + + Returns: "ok" | "refit" (узел был непригоден и починился) | "unfit" (только что + помечен непригодным) | "fail" (провал засчитан, порог не достигнут) | "ignored" + (провал не принадлежит узлу). + """ + if ok: + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies AS sp + SET browser_fail_streak = 0, + browser_unfit_since = NULL, + browser_check_at = now(), + updated_at = now() + -- prev — pre-image строки: RETURNING отдаёт УЖЕ обновлённые + -- значения (browser_unfit_since там всегда NULL), а нам нужно + -- знать, была ли это реанимация непригодного узла. + FROM ( + SELECT id, browser_unfit_since + FROM scrape_proxies + WHERE id = CAST(:id AS bigint) + ) AS prev + WHERE sp.id = prev.id + RETURNING (prev.browser_unfit_since IS NOT NULL) AS was_unfit + """ + ), + {"id": proxy_id}, + ) + .mappings() + .fetchone() + ) + db.commit() + was_unfit = bool(row["was_unfit"]) if row is not None else False + logger.info( + "proxy_pool: browser probe OK id=%d (%s)%s", + proxy_id, + detail, + " — узел снова пригоден для браузера" if was_unfit else "", + ) + return "refit" if was_unfit else "ok" + + if fail_kind != "proxy": + logger.warning( + "proxy_pool: browser probe FAILED id=%d, но отказ НЕ принадлежит узлу " + "(fail_kind=%s): %s — browser_fail_streak не трогаем", + proxy_id, + fail_kind, + detail, + ) + return "ignored" + + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies + SET browser_fail_streak = browser_fail_streak + 1, + browser_unfit_since = CASE + WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer) + AND browser_unfit_since IS NULL + THEN now() ELSE browser_unfit_since + END, + browser_check_at = CASE + WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer) + THEN now() ELSE browser_check_at + END, + updated_at = now() + WHERE id = CAST(:id AS bigint) + RETURNING browser_fail_streak, browser_unfit_since + """ + ), + {"threshold": BROWSER_UNFIT_THRESHOLD, "id": proxy_id}, + ) + .mappings() + .fetchone() + ) + db.commit() + if row is None: + logger.warning("proxy_pool: mark_browser_health id=%d not found — no-op", proxy_id) + return "ignored" + + streak = int(row["browser_fail_streak"]) + if streak >= BROWSER_UNFIT_THRESHOLD: + logger.warning( + "proxy_pool: proxy id=%d BROWSER-UNFIT (browser_fail_streak=%d) — жив для " + "обычного HTTP, но браузерный тракт через него не работает: %s. Узел " + "ОСТАЁТСЯ в пуле (enabled не тронут, curl-путь работает), но acquire() " + "теперь отдаёт его последним (#2723).", + proxy_id, + streak, + detail, + ) + return "unfit" + logger.warning( + "proxy_pool: browser probe FAILED id=%d (browser_fail_streak=%d/%d, порог не " + "достигнут — перепроверим на следующем прогоне): %s", + proxy_id, + streak, + BROWSER_UNFIT_THRESHOLD, + detail, + ) + return "fail" + + def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. @@ -645,8 +840,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: current = ( db.execute( text( - "SELECT enabled, disabled_reason FROM scrape_proxies " - "WHERE id = CAST(:id AS bigint)" + "SELECT enabled, disabled_reason FROM scrape_proxies WHERE id = CAST(:id AS bigint)" ), {"id": proxy_id}, ) @@ -775,6 +969,29 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No return False, None, None, "other" +async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str: + """Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health. + + Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет + healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не + имеет права ломать то, что диагностирует. + """ + from scraper_kit.browser_fetcher import probe_proxy_via_browser + + try: + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind + ) + except Exception: + logger.warning( + "proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан", + proxy_id, + exc_info=True, + ) + return "ignored" + return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail) + + def _mask(url: str) -> str: """Скрыть пароль в proxy-url для логов (scheme://user:***@host).""" if "@" not in url or "//" not in url: @@ -808,9 +1025,18 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад (см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count). + БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером + дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же + тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на + robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и + никогда не смешивается с consecutive_fails/enabled. Гейт — settings. + use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба + измеряла бы то, чем никто не пользуется. + Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters - {reaped, checked, ok, failed, revived, bans_purged}. + {reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok, + browser_unfit, browser_refit}. """ reaped = reap_stale_leases(db) @@ -818,7 +1044,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind, enabled, disabled_reason + SELECT id, url, kind, enabled, disabled_reason, + (browser_check_at IS NULL + OR browser_check_at < now() - make_interval( + mins => CAST(:browser_probe_minutes AS integer) + )) AS browser_probe_due FROM scrape_proxies WHERE enabled OR last_check_at IS NULL @@ -828,7 +1058,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: ORDER BY id """ ), - {"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES}, + { + "disabled_recheck_minutes": DISABLED_RECHECK_MINUTES, + "browser_probe_minutes": BROWSER_PROBE_MINUTES, + }, ) .mappings() .all() @@ -838,6 +1071,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: ok_count = 0 failed = 0 revived = 0 + browser_checked = 0 + browser_ok = 0 + browser_unfit = 0 + browser_refit = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) @@ -860,6 +1097,22 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: else: failed += 1 + # Браузерная проба (#2723) — только если ipify прошла: провалившая ipify нода + # мертва целиком, диагноз уже поставлен, а запуск camoufox через неё — чистая + # трата 8 секунд. Гейт по use_proxy_pool_browser: при выключенном флаге браузер + # ходит мимо пула (через env-прокси сайдкара), и вердикт об узлах пула был бы + # вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет + # не тот узел», из-за которого #2723 и появилась. + if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser: + outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"])) + browser_checked += 1 + if outcome in ("ok", "refit"): + browser_ok += 1 + if outcome == "refit": + browser_refit += 1 + elif outcome == "unfit": + browser_unfit += 1 + # Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS, # НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует # (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации. @@ -882,13 +1135,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: logger.info( "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d " - "bans_purged=%d", + "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d", reaped, checked, ok_count, failed, revived, purged, + browser_checked, + browser_ok, + browser_unfit, + browser_refit, ) return { "reaped": reaped, @@ -897,4 +1154,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: "failed": failed, "revived": revived, "bans_purged": purged, + # Счётчики браузерной пробы (#2723) — намеренно ОТДЕЛЬНЫЕ от checked/ok/failed: + # схлопнув их в общие, мы бы своими руками сделали то, за что чиним этот модуль. + "browser_checked": browser_checked, + "browser_ok": browser_ok, + "browser_unfit": browser_unfit, + "browser_refit": browser_refit, } diff --git a/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql b/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql new file mode 100644 index 00000000..71b4cd62 --- /dev/null +++ b/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql @@ -0,0 +1,66 @@ +-- 228_scrape_proxies_browser_health.sql +-- Здоровье узла ОТДЕЛЬНО для браузерного тракта (#2723). +-- +-- WHY: +-- `run_proxy_healthcheck` гоняет через узел обычный httpx-GET к ipify. Боевой сбор +-- Авито с 02.08 (#2637) ходит через сайдкар браузером: camoufox стартует С ЭТИМ +-- прокси (geoip-lookup на launch), потом навигация. Это разные свойства узла: +-- крошечный GET проходит там, где launch/навигация падает (`browser unavailable +-- (proxy may be down)` — все 90 записанных обрывов сбора именно такие). +-- +-- Хуже того, оба свойства писались в ОДИН счётчик: боевой /fetch репортит +-- mark_health(ok=False) → consecutive_fails++, но следующая (≤30 мин) успешная +-- ipify-проба делает consecutive_fails=0 + enabled=true. Дешёвая проба СТИРАЛА +-- вердикт дорогого тракта, и узел, мёртвый для браузера, вечно возвращался в +-- выдачу. Это ровно ошибка #2686 (схлопывание двух диагнозов в один флаг) в +-- другом месте; разводим её тем же приёмом, что #2711 (`scrape_runs.ban_kind`) — +-- поле РЯДОМ, а не новое значение существующего флага. +-- +-- WHAT (три колонки, ни одна не участвует в enabled/consecutive_fails): +-- browser_fail_streak — подряд-провалы ИМЕННО браузерной пробы, и только те, что +-- атрибутируются узлу (сайдкар лежит / страница пустая — +-- не считаются, см. proxy_pool._classify_browser_probe). +-- Успешная ipify-проба его НЕ обнуляет — в этом весь смысл. +-- browser_unfit_since — момент, когда streak дошёл до порога. NOT NULL = «жив для +-- HTTP, непригоден для браузера». acquire() такой узел НЕ +-- исключает (голодание хуже — #2600/#2638, пул 4 узла), а +-- отправляет в КОНЕЦ очереди выдачи: его возьмут, только +-- если свободных пригодных нет. +-- browser_check_at — когда браузерную пробу гоняли последний раз. Такт у неё +-- свой, редкий (BROWSER_PROBE_MINUTES): она стоит запуска +-- camoufox (~8с замерено на проде), ipify — миллисекунды. +-- +-- IDEMPOTENCY / SAFETY: +-- ADD COLUMN IF NOT EXISTS × 3, аддитивно, без backfill'а: NULL/0 = «браузерную +-- пробу ещё не гоняли», ровно то состояние, в котором пул и находится. Ни одна +-- существующая выборка не меняет результат (все три колонки новые). Повторный +-- прогон — no-op (auto-apply strict на деплое это требует). +-- +-- Dependencies: 157_scrape_proxies.sql + +BEGIN; + +ALTER TABLE scrape_proxies + ADD COLUMN IF NOT EXISTS browser_fail_streak integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS browser_unfit_since timestamptz, + ADD COLUMN IF NOT EXISTS browser_check_at timestamptz; + +COMMENT ON COLUMN scrape_proxies.browser_fail_streak IS + 'Подряд-провалы браузерной пробы (сайдкар + camoufox через ЭТОТ узел), ' + 'атрибутированные узлу. НЕ обнуляется успешной ipify-пробой — иначе дешёвая ' + 'проба стирает вердикт дорогого тракта (#2723). Обнуляется успешной браузерной ' + 'пробой. Порог → browser_unfit_since, см. proxy_pool.BROWSER_UNFIT_THRESHOLD.'; + +COMMENT ON COLUMN scrape_proxies.browser_unfit_since IS + 'NOT NULL = узел жив для обычного HTTP, но браузерный тракт через него не ' + 'работает (#2723). Это НЕ enabled=false: узел остаётся в пуле и обслуживает ' + 'curl-путь, а acquire() лишь отдаёт его последним. Полное выключение по-прежнему ' + 'значит «узел мёртв целиком» (серия транспортных сбоев) либо решение оператора.'; + +COMMENT ON COLUMN scrape_proxies.browser_check_at IS + 'Последняя браузерная проба. Такт свой, редкий (proxy_pool.BROWSER_PROBE_MINUTES): ' + 'одна такая проба = запуск camoufox (~8с на проде), против миллисекунд у ipify. ' + 'На неподтверждённом провале НЕ обновляется — чтобы следующий же цикл ' + 'healthcheck подтвердил/опроверг отказ, а не ждал полный такт.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index ca25dc81..a62f9886 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -188,9 +188,14 @@ class FakeSession: and _not_banned(r) and (not protects_last_node or _has_backup(r)) ] - # ORDER BY last_ok_at NULLS LAST, id + # ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id. + # Первый ключ гейтим по подстроке самого SQL (как ban-фильтры выше): иначе + # мок сортировал бы «правильно» независимо от боевого запроса и не отличил + # бы код до #2723 от кода после. + deprioritises_unfit = "browser_unfit_since IS NOT NULL" in sql cands.sort( key=lambda r: ( + bool(deprioritises_unfit and r.get("browser_unfit_since") is not None), r["last_ok_at"] is None, r["last_ok_at"] or datetime.min.replace(tzinfo=UTC), r["id"], @@ -250,6 +255,14 @@ class FakeSession: row["enabled"] = True elif "enabled" in sql: row["enabled"] = True + # #2723: если боевой mark_health когда-нибудь снова начнёт обнулять + # ещё и браузерный вердикт (как делал до фикса — тот жил в общем + # consecutive_fails), мок обязан это воспроизвести, иначе + # test_ipify_success_does_not_erase_browser_verdict останется зелёным + # на сломанном коде. + if "browser_fail_streak = 0" in sql: + row["browser_fail_streak"] = 0 + row["browser_unfit_since"] = None if "RETURNING disabled_reason" in sql: return _FakeResult([{"disabled_reason": row.get("disabled_reason")}]) return _FakeResult([]) @@ -272,8 +285,54 @@ class FakeSession: if r["enabled"] or r.get("last_check_at") is None or r["last_check_at"] < cutoff ] rows = sorted(cands, key=lambda r: r["id"]) + # #2723: браузерная проба со своим тактом. Признак считаем, только если + # боевой SQL его реально запрашивает (см. гейты по подстрокам выше). + if "browser_probe_due" in sql: + b_cutoff = datetime.now(UTC) - timedelta(minutes=p["browser_probe_minutes"]) + return _FakeResult( + [ + dict( + r, + browser_probe_due=( + r.get("browser_check_at") is None + or r["browser_check_at"] < b_cutoff + ), + ) + for r in rows + ] + ) return _FakeResult([dict(r) for r in rows]) + if "SET browser_fail_streak = 0" in sql: # mark_browser_health ok (#2723) + row = self._by_id(p["id"]) + if row is None: + return _FakeResult([]) + was_unfit = row.get("browser_unfit_since") is not None + row["browser_fail_streak"] = 0 + row["browser_unfit_since"] = None + row["browser_check_at"] = datetime.now(UTC) + return _FakeResult([{"was_unfit": was_unfit}]) + + if "browser_fail_streak = browser_fail_streak + 1" in sql: # mark_browser_health fail + row = self._by_id(p["id"]) + if row is None: + return _FakeResult([]) + row["browser_fail_streak"] = row.get("browser_fail_streak", 0) + 1 + if row["browser_fail_streak"] >= p["threshold"]: + if row.get("browser_unfit_since") is None: + row["browser_unfit_since"] = datetime.now(UTC) + # такт двигаем ТОЛЬКО на подтверждённом провале — иначе неподтверждённое + # подозрение ждало бы полный BROWSER_PROBE_MINUTES (#2723) + row["browser_check_at"] = datetime.now(UTC) + return _FakeResult( + [ + { + "browser_fail_streak": row["browser_fail_streak"], + "browser_unfit_since": row.get("browser_unfit_since"), + } + ] + ) + if "pg_advisory_xact_lock" in sql: # deep-review fix 2 (#2600) — mark_banned serialize self.advisory_lock_calls.append(p["key"]) return _FakeResult([]) @@ -384,6 +443,9 @@ def _proxy( kind: str = "http", rotate_url: str | None = None, disabled_reason: str | None = None, + browser_unfit_since: datetime | None = None, + browser_fail_streak: int = 0, + browser_check_at: datetime | None = None, ) -> dict[str, Any]: return { "id": pid, @@ -400,6 +462,10 @@ def _proxy( "last_check_at": last_check_at, "exit_ip": None, "latency_ms": None, + # #2723: здоровье браузерного тракта — отдельные поля, миграция 228. + "browser_unfit_since": browser_unfit_since, + "browser_fail_streak": browser_fail_streak, + "browser_check_at": browser_check_at, } diff --git a/tradein-mvp/backend/tests/test_2723_browser_probe.py b/tradein-mvp/backend/tests/test_2723_browser_probe.py new file mode 100644 index 00000000..0bfb8dae --- /dev/null +++ b/tradein-mvp/backend/tests/test_2723_browser_probe.py @@ -0,0 +1,329 @@ +"""#2723 — проба здоровья прокси ходит тем же трактом, что и работа. + +Что сторожится (каждый тест падает на коде origin/main): + + 1. Классификация отказа браузерной пробы: узлу принадлежит ТОЛЬКО отказ прокси + (503 «browser unavailable», 500 NS_ERROR_PROXY_*). Лежащий сайдкар и пустая + страница — не его вина. Без этого одна упавшая общая зависимость пометила бы + непригодными ВСЕ узлы разом — #2686 в третий раз. + 2. Тракт пробы: POST /fetch (одна навигация) на robots.txt, с прокси узла в теле. + Не /fetch-json (тот сначала грузит ГЛАВНУЮ площадки) и не выдача. + 3. Главное: успешная ipify-проба НЕ стирает вердикт браузерного тракта. На коде до + фикса узел, мёртвый для браузера, выходил из карантина каждые ≤30 минут + (mark_health(ok=True) → consecutive_fails=0 + enabled=true) и снова забирал прогон. + 4. Два диагноза разведены в обе стороны: провал браузерной пробы НЕ выключает узел + и НЕ трогает consecutive_fails; провал ipify не пишет ничего в browser-поля. + 5. Пометка непригодности НЕ выводит узел из пула: acquire() отдаёт его последним, + но при отсутствии пригодных всё равно выдаёт (голодание хуже) — пул из 4 узлов. + 6. Реанимация: успешная браузерная проба снимает пометку (browser_refit). + 7. Такт: браузерная проба идёт реже ipify (BROWSER_PROBE_MINUTES) и только по узлам, + прошедшим ipify — иначе на каждый прогон приходился бы запуск camoufox на узел. +""" + +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 pathlib import Path +from typing import Any + +import pytest +from scraper_kit.browser_fetcher import classify_browser_probe + +from app.services import proxy_pool +from app.services.proxy_pool import BROWSER_PROBE_MINUTES, BROWSER_UNFIT_THRESHOLD, acquire +from tests.services.test_proxy_pool import FakeSession, _proxy + +# ── 1. классификация отказа ────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("status", "detail", "expected"), + [ + # Ровно тот текст, которым сайдкар отвечал на все 90 записанных обрывов сбора. + (503, '{"error": "browser unavailable (proxy may be down)"}', "proxy"), + (500, '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY ..."}', "proxy"), + (500, '{"error": "Error: Page.goto: NS_ERROR_UNKNOWN_PROXY_HOST"}', "proxy"), + # Сайдкар не сконфигурирован / лежит / отвечает чем-то ещё — узел ни при чём. + (503, '{"error": "no proxy configured — refusing direct connection (prod)"}', "sidecar"), + (502, "bad gateway", "sidecar"), + (None, "ConnectError: [Errno 111] Connection refused", "sidecar"), + # Тракт сработал, но ответ не похож на страницу — вопрос к площадке, не к пулу. + (200, "", "page"), + ], +) +def test_classify_browser_probe(status: int | None, detail: str, expected: str) -> None: + assert classify_browser_probe(status, detail) == expected + + +def test_sidecar_error_literals_still_exist() -> None: + """Тripwire: классификация опирается на текст отказа сайдкара — сторожим его. + + Если browser/server.py переименует сообщение, «proxy» перестанет распознаваться и + непригодный узел молча останется первосортным. Тест падает СРАЗУ, а не через месяц + зелёных проб (ровно тот сценарий, из-за которого задача и появилась). + """ + server_py = Path(__file__).resolve().parents[2] / "browser" / "server.py" + src = server_py.read_text(encoding="utf-8") + assert "browser unavailable (proxy may be down)" in src + + +# ── 2. тракт пробы ─────────────────────────────────────────────────────────── + + +async def test_probe_goes_through_sidecar_with_node_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + """Проба = POST /fetch на robots.txt с прокси УЗЛА в теле, а не httpx-GET мимо всех.""" + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = '{"html": "User-agent: *"}' + + @staticmethod + def json() -> dict[str, str]: + return {"html": "User-agent: *"} + + class _Client: + def __init__(self, **kw: Any) -> None: + seen["timeout"] = kw.get("timeout") + + 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["url"] = url + seen["payload"] = json + return _Resp() + + import scraper_kit.browser_fetcher as bf + + 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", proxy_kind="http" + ) + + assert ok is True + assert fail_kind is None + # тот же сайдкар и тот же эндпоинт, что у боевого сбора + assert seen["url"] == "http://tradein-browser:3000/fetch" + # НЕ /fetch-json: он делает goto на главную площадки — это уже нагрузка на неё + assert not seen["url"].endswith("/fetch-json") + # прокси проверяемого узла уезжает в тело — иначе camoufox пойдёт через env-прокси + # и проба снова будет измерять не тот узел + assert seen["payload"]["proxy"] == "http://u:p@node:8080" + # адрес — robots.txt площадки, не выдача и не карточка + assert seen["payload"]["url"].endswith("/robots.txt") + assert "avito.ru" in seen["payload"]["url"] + + +# ── 3-4. два диагноза разведены ────────────────────────────────────────────── + + +def test_ipify_success_does_not_erase_browser_verdict() -> None: + """ГЛАВНОЕ: успешная ipify-проба не воскрешает узел, мёртвый для браузера. + + До #2723 браузерный вердикт жил в consecutive_fails, и mark_health(ok=True) + обнулял его каждые ≤30 минут вместе с enabled=true. + """ + db = FakeSession([_proxy(1)]) + for _ in range(BROWSER_UNFIT_THRESHOLD): + proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") + row = db._by_id(1) + assert row["browser_unfit_since"] is not None + + proxy_pool.mark_health(db, 1, True, exit_ip="1.2.3.4", latency_ms=100) + + row = db._by_id(1) + assert row["consecutive_fails"] == 0 # HTTP-диагноз сброшен, как и раньше + assert row["browser_unfit_since"] is not None # а браузерный — НЕТ + assert row["browser_fail_streak"] >= BROWSER_UNFIT_THRESHOLD + + +def test_browser_failure_does_not_disable_node() -> None: + """Обратная сторона: провал браузерного тракта не выключает живой узел.""" + db = FakeSession([_proxy(1)]) + for _ in range(BROWSER_UNFIT_THRESHOLD + 3): + proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") + row = db._by_id(1) + assert row["enabled"] is True # узел жив для HTTP — из пула не выводим + assert row["consecutive_fails"] == 0 # и транспортный счётчик не трогаем + assert row["browser_unfit_since"] is not None + + +def test_sidecar_outage_blames_nobody() -> None: + """Лежащий сайдкар не должен пометить непригодными все узлы разом (#2686-класс).""" + db = FakeSession([_proxy(1), _proxy(2)]) + for pid in (1, 2): + for _ in range(BROWSER_UNFIT_THRESHOLD + 1): + outcome = proxy_pool.mark_browser_health( + db, pid, False, fail_kind="sidecar", detail="ConnectError" + ) + assert outcome == "ignored" + for pid in (1, 2): + assert db._by_id(pid)["browser_unfit_since"] is None + assert db._by_id(pid)["browser_fail_streak"] == 0 + + +def test_unconfirmed_failure_keeps_check_at_stale() -> None: + """Первый (неподтверждённый) провал не двигает такт — перепроверка на след. прогоне.""" + db = FakeSession([_proxy(1, browser_check_at=None)]) + proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") + assert db._by_id(1)["browser_fail_streak"] == 1 + assert db._by_id(1)["browser_check_at"] is None # такт не сдвинут + proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") + assert db._by_id(1)["browser_unfit_since"] is not None + assert db._by_id(1)["browser_check_at"] is not None # подтверждён → ждём полный такт + + +# ── 5. пометка не выводит узел из пула ─────────────────────────────────────── + + +def test_unfit_node_is_last_in_queue_but_still_reachable() -> None: + old = datetime.now(UTC) - timedelta(hours=5) + db = FakeSession( + [ + # непригодный, но «давно не использованный» → до #2723 выдавался ПЕРВЫМ + _proxy(1, last_ok_at=old, browser_unfit_since=datetime.now(UTC)), + _proxy(2, last_ok_at=datetime.now(UTC)), + ] + ) + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None + assert lease.id == 2 # пригодный вперёд, несмотря на ORDER BY last_ok_at + + +def test_all_unfit_still_yields_a_proxy() -> None: + """Все узлы непригодны — система НЕ остаётся без прокси (голодание хуже).""" + db = FakeSession( + [ + _proxy(1, browser_unfit_since=datetime.now(UTC)), + _proxy(2, browser_unfit_since=datetime.now(UTC)), + ] + ) + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None + + +# ── 6-7. healthcheck: такт, гейт, реанимация ───────────────────────────────── + + +def _patch_probes( + monkeypatch: pytest.MonkeyPatch, + *, + http_ok: bool = True, + browser: tuple[bool, str | None, str] = (True, None, "html_len=100"), + calls: list[str] | None = None, +) -> None: + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return (True, "1.2.3.4", 10, None) if http_ok else (False, None, None, "timeout") + + async def _fake_browser( + endpoint: str, proxy_url: str, **_kw: Any + ) -> tuple[bool, str | None, str]: + if calls is not None: + calls.append(proxy_url) + return browser + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + import scraper_kit.browser_fetcher as bf + + monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) + + +async def test_healthcheck_marks_unfit_when_http_green_browser_red( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Исторический случай целиком: ipify зелёная, браузер красный → диагноз ставится.""" + calls: list[str] = [] + _patch_probes( + monkeypatch, + http_ok=True, + browser=(False, "proxy", "503 browser unavailable (proxy may be down)"), + calls=calls, + ) + db = FakeSession([_proxy(1)]) + + first = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert first["ok"] == 1 and first["failed"] == 0 # HTTP-проба по-прежнему зелёная + assert first["browser_checked"] == 1 + assert db._by_id(1)["browser_unfit_since"] is None # один провал ещё не приговор + + second = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert second["browser_unfit"] == 1 + row = db._by_id(1) + assert row["browser_unfit_since"] is not None + assert row["enabled"] is True and row["consecutive_fails"] == 0 + assert len(calls) == 2 + + +async def test_healthcheck_browser_probe_respects_slow_tick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.""" + calls: list[str] = [] + _patch_probes(monkeypatch, calls=calls) + db = FakeSession([_proxy(1)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert len(calls) == 1 + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox" + + db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta( + minutes=BROWSER_PROBE_MINUTES + 1 + ) + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert len(calls) == 2 + + +async def test_healthcheck_skips_browser_probe_when_http_dead( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узел, не прошедший ipify, мёртв целиком — жечь на него запуск camoufox незачем.""" + calls: list[str] = [] + _patch_probes(monkeypatch, http_ok=False, calls=calls) + db = FakeSession([_proxy(1)]) + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert counters["failed"] == 1 + assert counters["browser_checked"] == 0 + assert calls == [] + + +async def test_healthcheck_skips_browser_probe_when_pool_not_wired( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Флаг выключен → браузер ходит мимо пула, вердикт об узлах пула бессмыслен.""" + calls: list[str] = [] + _patch_probes(monkeypatch, calls=calls) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", False) + db = FakeSession([_proxy(1)]) + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert counters["browser_checked"] == 0 + assert calls == [] + + +async def test_healthcheck_revives_unfit_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Путь обратно: успешная браузерная проба снимает пометку непригодности.""" + _patch_probes(monkeypatch) + db = FakeSession( + [ + _proxy( + 1, + browser_unfit_since=datetime.now(UTC) - timedelta(days=1), + browser_fail_streak=4, + browser_check_at=datetime.now(UTC) - timedelta(minutes=BROWSER_PROBE_MINUTES + 1), + ) + ] + ) + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert counters["browser_refit"] == 1 + row = db._by_id(1) + assert row["browser_unfit_since"] is None + assert row["browser_fail_streak"] == 0 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index c58ed589..640f7492 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -36,6 +36,42 @@ logger = logging.getLogger(__name__) _RETRY_SLEEP_S: float = 1.0 _HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут +# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ───────────────────────────────── +# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает: +# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая +# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may +# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать; +# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие +# и маршрут до её edge, а не до нейтрального хоста; +# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который +# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка. +# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16 +# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08). +_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt" +# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу +# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок +# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот +# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые +# развёртки его не используют. +_PROXY_PROBE_SOURCE: str = "generic" +# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с +# замерено на проде вместе с релончем, плюс запас на медленный узел. +_PROXY_PROBE_TIMEOUT_S: float = 90.0 + +# Маркеры отказов, которые сайдкар порождает ИМЕННО из-за прокси (browser/server.py: +# fetch_handler 503 после _ensure_browser → camoufox не поднялся с этим прокси; +# 500 с NS_ERROR_PROXY_* → навигация не прошла через прокси). Всё остальное — +# не про узел (сайдкар недоступен, конфиг сайдкара, пустая страница). +# ponytail: подстроки, а не машинный код отказа — сайдкар не отдаёт поле причины. +# Тест test_2723_browser_probe.py::test_sidecar_error_literals_still_exist сторожит +# расхождение с исходником сайдкара; при следующей правке browser/server.py дешевле +# добавить туда {"fail_kind": "proxy"} и читать его здесь. +_PROXY_FAIL_MARKERS: tuple[str, ...] = ( + "browser unavailable (proxy may be down)", + "NS_ERROR_PROXY", + "NS_ERROR_UNKNOWN_PROXY_HOST", +) + # Живая регрессия 2026-08: после скольких подряд провалившихся /fetch ТЕКУЩИЙ session-lease # считается плохим (бан/сетевая труха) и ОСОЗНАННО меняется один раз (release+acquire), вместо # того чтобы менять прокси на каждый /fetch как раньше. Camoufox релончится ТОЛЬКО при реальной @@ -78,6 +114,79 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None: ) from exc +def classify_browser_probe(status: int | None, detail: str) -> str: + """Кому принадлежит отказ браузерной пробы: узлу, сайдкару или странице (#2723). + + Разведение обязательно, иначе повторяется #2686 в третий раз: лежащий сайдкар + пометил бы НЕПРИГОДНЫМИ ВСЕ узлы разом, хотя ни один из них не при чём. + + - "proxy" — отказ порождён прокси: camoufox не поднялся с ним (503 «browser + unavailable (proxy may be down)») либо навигация не прошла через + него (500 NS_ERROR_PROXY_*). ТОЛЬКО этот исход копит + browser_fail_streak. + - "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут, + 503 «no proxy configured», прочие 5xx). Узел не виноват. + - "page" — тракт сработал, но ответ не похож на страницу (пустое тело). + Узел не виноват; повод посмотреть на площадку, не на пул. + """ + if status is None: + return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера + if any(marker in detail for marker in _PROXY_FAIL_MARKERS): + return "proxy" + if status >= 400: + return "sidecar" + return "page" + + +async def probe_proxy_via_browser( + endpoint: str, + proxy_url: str, + *, + proxy_kind: str = "http", + url: str = _PROXY_PROBE_URL, + timeout_s: float = _PROXY_PROBE_TIMEOUT_S, +) -> tuple[bool, str | None, str]: + """Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси. + + Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и + вредна — health-checker проверяет узлы, в том числе арендованные, и не должен + конкурировать за lease с боевым прогоном. + + Используется `/fetch` (одна навигация), а НЕ `/fetch-json`: последний сначала + делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная + нагрузка на неё, ради которой проба и затевалась бы наоборот. + + Returns: + (ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind — + "proxy" / "sidecar" / "page" (см. classify_browser_probe), detail — + обрезанный текст для лога. + """ + payload: dict[str, object] = { + "url": url, + "source": _PROXY_PROBE_SOURCE, + "proxy": proxy_url, + "proxy_kind": proxy_kind, + } + try: + async with httpx.AsyncClient(timeout=timeout_s) as client: + resp = await client.post(f"{endpoint}/fetch", json=payload) + except Exception as exc: + detail = f"{type(exc).__name__}: {str(exc)[:200]}" + return False, classify_browser_probe(None, detail), detail + + detail = " ".join((resp.text or "").split())[:300] + if resp.status_code != 200: + return False, classify_browser_probe(resp.status_code, detail), detail + + try: + html = resp.json().get("html") or "" + except Exception: + html = "" + if not html: + return False, classify_browser_probe(resp.status_code, detail), "empty html" + return True, None, f"html_len={len(html)}" + + class BrowserFetcher: """Async context manager: HTTP-клиент к tradein-browser HTTP-сервису. From dccd2d42728160adf8383d936fdc4cbcf4745255 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 15:56:02 +0300 Subject: [PATCH 056/130] =?UTF-8?q?chore(tradein/privacy):=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D1=83=D0=BC=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B8=20merge=20main=20-=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20PR=20(#2547)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 192/193 -> 229/230: main занял 192_tradein_users_auth.sql и 193_tradein_users_seed.sql за время простоя PR. 228 зарезервирован открытым PR #2732 (228_payments.sql) - следующие реально свободные 229/230, порядок consent_proof -> retention сохранён. Правки ссылок на старые имена/префиксы: docstring-заголовки самих SQL-файлов, перекрёстная ссылка 229 -> 230 в комментарии-докстринге, комментарии migration 192/193 в lead.py / config.py / schemas/trade_in.py / purge_expired_trade_in_data.py, переменные и имена тестов в test_estimate_consent_gate.py / test_purge_expired_trade_in_data.py. (Оставлены нетронутыми ссылки на migration 192/193 в auth_session.py и test_team_api.py - это про другие, уже существующие на main миграции 192_tradein_users_auth.sql / 193_tradein_users_seed.sql, не про эту пару.) --- tradein-mvp/backend/app/api/v1/lead.py | 2 +- tradein-mvp/backend/app/core/config.py | 2 +- tradein-mvp/backend/app/schemas/trade_in.py | 2 +- .../app/tasks/purge_expired_trade_in_data.py | 4 ++-- ... 229_trade_in_estimates_consent_proof.sql} | 4 ++-- ...sql => 230_trade_in_privacy_retention.sql} | 4 ++-- .../tests/test_estimate_consent_gate.py | 24 +++++++++---------- .../tests/test_purge_expired_trade_in_data.py | 24 +++++++++---------- 8 files changed, 33 insertions(+), 33 deletions(-) rename tradein-mvp/backend/data/sql/{192_trade_in_estimates_consent_proof.sql => 229_trade_in_estimates_consent_proof.sql} (98%) rename tradein-mvp/backend/data/sql/{193_trade_in_privacy_retention.sql => 230_trade_in_privacy_retention.sql} (98%) diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 41ebe082..54795e32 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -118,7 +118,7 @@ async def create_trade_in_lead( # ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет # X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден. # - # ЭТАП 4 B2C: expires_at (migration 193) — раньше лид хранился бессрочно + # ЭТАП 4 B2C: expires_at (migration 230) — раньше лид хранился бессрочно # (никакого TTL вообще не было, в отличие от trade_in_estimates.expires_at). # Считаем на insert-time тем же паттерном, что estimator.py делает для # trade_in_estimates — retention-период вынесен в settings, не хардкод. diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index e2d55fa3..0e716969 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -824,7 +824,7 @@ class Settings(BaseSettings): # к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS. trade_in_estimate_retention_hours: int = 24 - # trade_in_leads.expires_at TTL (дни от момента создания, migration 193). + # trade_in_leads.expires_at TTL (дни от момента создания, migration 230). # У trade_in_leads раньше вообще не было срока хранения — лид (телефон + # согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для # НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 63e33cd5..ad4811d0 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -48,7 +48,7 @@ class TradeInEstimateInput(BaseModel): has_mortgage: bool | None = None # client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167). - # ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 192). + # ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 229). # Enforcement (НЕ здесь): app.services.estimator.estimate_quality проверяет # `created_by is None and not consent -> 422` ДО первого INSERT адреса в # trade_in_estimates. Здесь поле намеренно `bool | None = None`, а НЕ diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index 17a99508..dcdaad71 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -1,7 +1,7 @@ """Physically delete expired personal data — ЭТАП 4 B2C retention enforcement (152-ФЗ). WHY: - trade_in_estimates.expires_at (и, начиная с migration 193, trade_in_leads.expires_at) + trade_in_estimates.expires_at (и, начиная с migration 230, trade_in_leads.expires_at) defined a retention window, but neither table had any background job that actually DELETEd rows once expired -- expires_at was used ONLY as a read-time filter (GET /estimate/{id}: "AND expires_at > NOW()"). Personal data (address / phone) @@ -11,7 +11,7 @@ WHY: WHAT: Batched physical DELETE for both tables, run nightly by the kit-scheduler (see app.services.product_handlers._job_purge_expired_trade_in_data, scrape_schedules - row seeded by migration 193 -- seeded enabled=false, see that migration's docstring + row seeded by migration 230 -- seeded enabled=false, see that migration's docstring for why). Same architecture as app/tasks/deactivate_stale_avito.py (sync, DB-only, invoked via run_in_executor from the async kit handler). diff --git a/tradein-mvp/backend/data/sql/192_trade_in_estimates_consent_proof.sql b/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql similarity index 98% rename from tradein-mvp/backend/data/sql/192_trade_in_estimates_consent_proof.sql rename to tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql index 78ff74e5..ee3bfbf8 100644 --- a/tradein-mvp/backend/data/sql/192_trade_in_estimates_consent_proof.sql +++ b/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql @@ -1,4 +1,4 @@ --- 192_trade_in_estimates_consent_proof.sql +-- 229_trade_in_estimates_consent_proof.sql -- ЭТАП 4 B2C launch — правовая рамка для анонимных пользователей (152-ФЗ). -- -- WHY: @@ -41,7 +41,7 @@ -- defense-in-depth на случай будущего кода, который забудет про gate). -- -- Индекс на expires_at — обслуживает будущую retention-задачу --- purge_expired_trade_in_data (см. migration 193), которая физически +-- purge_expired_trade_in_data (см. migration 230), которая физически -- удаляет строки, чей expires_at истёк (сегодня expires_at используется -- ТОЛЬКО как read-time фильтр, см. GET /estimate/{id}: "AND expires_at > -- NOW()" — без индекса такой batched-DELETE делал бы full scan таблицы diff --git a/tradein-mvp/backend/data/sql/193_trade_in_privacy_retention.sql b/tradein-mvp/backend/data/sql/230_trade_in_privacy_retention.sql similarity index 98% rename from tradein-mvp/backend/data/sql/193_trade_in_privacy_retention.sql rename to tradein-mvp/backend/data/sql/230_trade_in_privacy_retention.sql index 822a0526..d0d5817b 100644 --- a/tradein-mvp/backend/data/sql/193_trade_in_privacy_retention.sql +++ b/tradein-mvp/backend/data/sql/230_trade_in_privacy_retention.sql @@ -1,4 +1,4 @@ --- 193_trade_in_privacy_retention.sql +-- 230_trade_in_privacy_retention.sql -- ЭТАП 4 B2C launch — retention enforcement (152-ФЗ): срок хранения ДОЛЖЕН -- приводить к физическому удалению, а не быть декоративным полем. -- @@ -47,7 +47,7 @@ -- - INSERT ... ON CONFLICT (source) DO NOTHING — безопасный re-run seed'а. -- -- Dependencies: 172_trade_in_leads.sql (таблица), 052_scrape_schedules.sql --- (scrape_schedules), 192_trade_in_estimates_consent_proof.sql (соседняя +-- (scrape_schedules), 229_trade_in_estimates_consent_proof.sql (соседняя -- часть той же ЭТАП 4 инициативы — индекс на trade_in_estimates.expires_at -- уже создан там). diff --git a/tradein-mvp/backend/tests/test_estimate_consent_gate.py b/tradein-mvp/backend/tests/test_estimate_consent_gate.py index 28a3d13a..4dc9316c 100644 --- a/tradein-mvp/backend/tests/test_estimate_consent_gate.py +++ b/tradein-mvp/backend/tests/test_estimate_consent_gate.py @@ -44,7 +44,7 @@ import pytest from fastapi import HTTPException _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" -_MIGRATION_192 = _SQL_DIR / "192_trade_in_estimates_consent_proof.sql" +_MIGRATION_229 = _SQL_DIR / "229_trade_in_estimates_consent_proof.sql" def _make_payload(**overrides: Any) -> Any: @@ -288,32 +288,32 @@ def test_api_handler_wires_require_consent_from_auth_header() -> None: assert "require_consent=x_authenticated_user is None" in src -# ── Migration 192 sanity ──────────────────────────────────────────────────────── +# ── Migration 229 sanity ──────────────────────────────────────────────────────── -def test_migration_192_exists() -> None: - assert _MIGRATION_192.is_file(), f"missing migration: {_MIGRATION_192}" +def test_migration_229_exists() -> None: + assert _MIGRATION_229.is_file(), f"missing migration: {_MIGRATION_229}" -def test_migration_192_is_transactional() -> None: - sql = _MIGRATION_192.read_text("utf-8") +def test_migration_229_is_transactional() -> None: + sql = _MIGRATION_229.read_text("utf-8") assert "BEGIN;" in sql assert "COMMIT;" in sql -def test_migration_192_is_idempotent() -> None: - sql = _MIGRATION_192.read_text("utf-8") +def test_migration_229_is_idempotent() -> None: + sql = _MIGRATION_229.read_text("utf-8") assert "ADD COLUMN IF NOT EXISTS consent" in sql assert "ADD COLUMN IF NOT EXISTS client_ip" in sql assert "CREATE INDEX IF NOT EXISTS" in sql assert "pg_constraint" in sql # DO-block guard, not bare ADD CONSTRAINT -def test_migration_192_no_psycopg_trap() -> None: - sql = _MIGRATION_192.read_text("utf-8") +def test_migration_229_no_psycopg_trap() -> None: + sql = _MIGRATION_229.read_text("utf-8") assert not re.search(r":\w+::", sql) -def test_migration_192_check_constraint_allows_null_or_true() -> None: - sql = _MIGRATION_192.read_text("utf-8") +def test_migration_229_check_constraint_allows_null_or_true() -> None: + sql = _MIGRATION_229.read_text("utf-8") assert "consent IS NULL OR consent IS TRUE" in sql diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index e32b4570..20a695e2 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -26,7 +26,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from app.tasks import purge_expired_trade_in_data as task_mod _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" -_MIGRATION_193 = _SQL_DIR / "193_trade_in_privacy_retention.sql" +_MIGRATION_230 = _SQL_DIR / "230_trade_in_privacy_retention.sql" class _FakeResult: @@ -197,29 +197,29 @@ def test_idempotent_zero_rowcount_is_not_an_error(monkeypatch: pytest.MonkeyPatc assert marked["kind"] == "done" -# ── migration 193 ──────────────────────────────────────────────────────────── +# ── migration 230 ──────────────────────────────────────────────────────────── -def test_migration_193_exists() -> None: - assert _MIGRATION_193.is_file(), f"missing migration: {_MIGRATION_193}" +def test_migration_230_exists() -> None: + assert _MIGRATION_230.is_file(), f"missing migration: {_MIGRATION_230}" -def test_migration_193_is_transactional() -> None: - sql = _MIGRATION_193.read_text("utf-8") +def test_migration_230_is_transactional() -> None: + sql = _MIGRATION_230.read_text("utf-8") assert "BEGIN;" in sql assert "COMMIT;" in sql -def test_migration_193_backfills_and_sets_not_null() -> None: - sql = _MIGRATION_193.read_text("utf-8") +def test_migration_230_backfills_and_sets_not_null() -> None: + sql = _MIGRATION_230.read_text("utf-8") assert "ADD COLUMN IF NOT EXISTS expires_at" in sql assert "WHERE expires_at IS NULL" in sql assert "SET NOT NULL" in sql assert "180 days" in sql -def test_migration_193_seeds_purge_schedule_disabled_by_default() -> None: - sql = _MIGRATION_193.read_text("utf-8") +def test_migration_230_seeds_purge_schedule_disabled_by_default() -> None: + sql = _MIGRATION_230.read_text("utf-8") assert "'purge_expired_trade_in_data'" in sql assert "ON CONFLICT (source) DO NOTHING" in sql # Seeded disabled -- first automated PII-DELETE job in trade-in deserves a @@ -227,6 +227,6 @@ def test_migration_193_seeds_purge_schedule_disabled_by_default() -> None: assert re.search(r"'purge_expired_trade_in_data',\s*\n\s*false,", sql) -def test_migration_193_no_psycopg_trap() -> None: - sql = _MIGRATION_193.read_text("utf-8") +def test_migration_230_no_psycopg_trap() -> None: + sql = _MIGRATION_230.read_text("utf-8") assert not re.search(r":\w+::", sql) From 90e328df66fa4631a898f0c7d4c795763c14c8ef Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 14:27:26 +0000 Subject: [PATCH 057/130] =?UTF-8?q?fix(tradein/auth):=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=20=D0=BF=D0=BE=20=D0=BD=D0=B0=D1=81=D1=8B=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8E=20=E2=80=94=20=D0=B4=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D0=B8=D0=B7=20=D0=91?= =?UTF-8?q?=D0=94=20=D0=B8=20=D1=81=20=D0=B0=D0=B3=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D0=BC=20(#2715)=20(#2734)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/audit.py | 17 +- tradein-mvp/backend/app/api/v1/auth.py | 147 ++++++++++++++-- tradein-mvp/backend/app/core/password.py | 33 +++- tradein-mvp/backend/tests/test_audit_api.py | 33 ++++ tradein-mvp/backend/tests/test_auth_api.py | 182 ++++++++++++++++++++ 5 files changed, 394 insertions(+), 18 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/audit.py b/tradein-mvp/backend/app/api/v1/audit.py index e592d5b1..feac11aa 100644 --- a/tradein-mvp/backend/app/api/v1/audit.py +++ b/tradein-mvp/backend/app/api/v1/audit.py @@ -58,6 +58,12 @@ async def list_accounts( count(*) FILTER (WHERE event_type = 'api_request') AS request_count, count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count FROM user_events + -- Событие без имени — не аккаунт (#2715: `login_verify_saturated` + -- пишется с пустым именем намеренно — отказ случается ДО того, как + -- на имя посмотрели). Без фильтра строка встала бы ПЕРВОЙ (её + -- last_seen_at — момент атаки), а её кнопка в UI раскрывалась бы в + -- /audit/accounts/{username} с `min_length=1`, то есть в ошибку. + WHERE username <> '' GROUP BY username ORDER BY last_seen_at DESC """ @@ -182,12 +188,16 @@ async def analytics_dashboard( db.execute( text( """ + -- NULLIF(username, ''): безымянные события (#2715) — СОБЫТИЯ, они + -- честно входят в total_events, но не люди: count(DISTINCT) их + -- игнорирует по NULL, иначе первая же атака навсегда добавила бы + -- фантомного пользователя в счётчик уникальных. SELECT count(*) AS total_events, - count(DISTINCT username) AS distinct_users, + count(DISTINCT NULLIF(username, '')) AS distinct_users, count(*) FILTER ( WHERE created_at >= now() - INTERVAL '24 hours' ) AS events_last_24h, - count(DISTINCT username) FILTER ( + count(DISTINCT NULLIF(username, '')) FILTER ( WHERE created_at >= now() - INTERVAL '24 hours' ) AS active_users_last_24h FROM user_events @@ -204,7 +214,7 @@ async def analytics_dashboard( """ SELECT date_trunc('day', created_at)::date AS day, count(*) AS events, - count(DISTINCT username) AS users + count(DISTINCT NULLIF(username, '')) AS users -- см. выше (#2715) FROM user_events WHERE created_at >= now() - make_interval(days => CAST(:days AS int)) GROUP BY date_trunc('day', created_at)::date @@ -261,6 +271,7 @@ async def analytics_dashboard( count(*) FILTER (WHERE event_type = 'estimate_request') AS searches, max(created_at) AS last_seen FROM user_events + WHERE username <> '' -- не аккаунт, см. /audit/accounts выше (#2715) GROUP BY username ORDER BY events DESC LIMIT 50 diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index bfe43cf6..af346cde 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -40,6 +40,10 @@ Security: флуда получал 429 столько раз, сколько пытался. Ключ — IP, поэтому защита поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси, общий адрес за NAT, ротация через ботнет) — см. docstring той же функции. + Отказ по насыщению выдаётся ДО выборки из реестра (#2715): иначе на этом + пути оставалась бы единственная работа, время которой зависит от того, + существует ли имя, — а bcrypt, который эту разницу ровняет, до него уже не + доходит. След инцидента — агрегированный, `_saturated_429`. - Поверх него — ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571): лимит по паре (username, IP) распределённый перебор обходит целиком, просто меняя адрес. Превышение порога не блокирует вход, а замедляет ответ @@ -54,6 +58,7 @@ from __future__ import annotations import asyncio import logging import secrets +import time from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -61,7 +66,12 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.config import settings -from app.core.password import PasswordVerifyOverloadedError, hash_password, verify_password_bounded +from app.core.password import ( + PasswordVerifyOverloadedError, + hash_password, + verify_password_bounded, + verify_slots_saturated, +) from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.services.auth_session import create_session, get_user_by_username, revoke_session from app.services.identity_store import AccessState, get_identity_db @@ -174,6 +184,115 @@ def _throttle_delay_s(fails_in_window: int) -> float: return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16)) +# Не чаще одной записи в это окно на ВСЕ отказы по насыщению (#2715). Окно, а не +# запись на запрос, потому что лог у бэкенда общий и ограниченный (docker +# json-file, max-size 20m × max-file 3): при флуде в сотни запросов в секунду +# строка на каждый отказ прокручивает 60 МБ за минуты и выселяет ВСЕ остальные +# логи ровно во время атаки — то есть в момент, когда они нужнее всего. +# Значение не в настройках намеренно: это не тюнинг, а «человек читает лог», и +# крутить его нечем — меньше секунды возвращает исходную проблему, больше +# ухудшает разрешение по времени, не давая взамен ничего. +_SATURATION_REPORT_WINDOW_S = 1.0 + +# Отказов с прошлой записи и когда была прошлая запись (monotonic; None — записи +# ещё не было). Обычные глобалы без лока — по той же причине, что и счётчик +# слотов в `app.core.password`: обе строчки исполняются в потоке событийного +# цикла и между чтением и записью нет `await`. +_saturation_rejected = 0 +_saturation_reported_at: float | None = None + + +def _saturated_429(ip: str) -> HTTPException: + """429 «слоты сверки заняты» + АГРЕГИРОВАННЫЙ след инцидента. + + Событие неудачного входа тут не пишется и бюджет неудач по имени не + тратится сознательно (#2712): пароль не проверялся, это не попытка входа, а + трата бюджета означала бы, что насыщением можно заблокировать чужую учётку. + Но тогда весь инцидент виден ровно здесь, и до #2715 — только строкой в + логе на каждый отклонённый запрос (см. `_SATURATION_REPORT_WINDOW_S`). + + Поэтому на окно приходится одна строка в лог И одно событие + `login_verify_saturated` в `user_events` — с числом отказов, накопленных с + прошлой записи. Событие важнее строки: аудит переживает и ротацию логов, и + редеплой. Первый отказ отчитывается сразу, а не в конце окна: одиночная + аномалия обязана быть видна, даже если продолжения не будет. + + `since_prev_s` в payload — НЕ дубль `created_at`, а единственный способ + прочитать счётчик правильно. Хвост копится, пока не придёт следующий отказ: + атака кончилась в 03:00, 900 отказов остались неотчитанными — и во вторник + одиночный 429 соседа по NAT унёс бы их все в запись, датированную вторником + и подписанную АДРЕСОМ СОСЕДА. С `since_prev_s` видно, что 901 отказ + накоплен за неделю, а не за секунду, и что читать `ip` в этой записи не + надо. `None` — первая запись за жизнь процесса, сравнивать не с чем. + + Уровень ERROR, а не WARNING, — не косметика: бэкенд поднят с + `LoggingIntegration(level=INFO, event_level=ERROR)` (app/main.py), то есть + ровно с ERROR запись становится событием GlitchTip, а WARNING остаётся + строкой в docker-логе, которая умирает с ротацией и редеплоем. Цена + прецедента известна (#2674): монитор писал WARNING про протухшие куки — и + событий было ноль. Спама не будет: запись не чаще раза в окно, и все они + группируются в один issue (шаблон сообщения один). + + Чего это НЕ делает: у GlitchTip-проекта нет ни правил, ни получателей + (#2673), так что уведомление никому не уйдёт — событие будет видно в + интерфейсе, но не в чьём-то телефоне. Проверить доставку поведенчески + сейчас не на чем, и утверждать её здесь было бы враньём. + + `username=""` — не заглушка: имя не пишем ПОТОМУ, что отказ случился до + того, как мы на него посмотрели. Записывай мы присланное, атакующий + наполнял бы аудит строками с любым именем на выбор. Пустое имя — не аккаунт, + и списки аудита его отфильтровывают (`WHERE username <> ''` в + `app/api/v1/audit.py`), иначе оно встало бы первой строкой в списке + аккаунтов и фантомом в `count(DISTINCT username)`. `ip` — адрес последнего + отклонённого запроса, то есть ОБРАЗЕЦ: при распределённом флуде адресов + много, и по одной записи их не восстановить (счётчик — восстановит). + + Потолок объёма: час непрерывной атаки — это 3600 строк в `user_events` + (в таблице за всю её жизнь ~3.4 тысячи), сутки — под 86 тысяч. Retention у + таблицы нет, а `GET /audit/accounts` делает полный `GROUP BY` без фильтра по + времени. То же давление уходит на квоту проекта в GlitchTip — тот же + механизм вытеснения чужого сигнала, только в другом ведре. Дойдёт до этого — + окно агрегации растёт с длительностью атаки (экспонента с потолком, как у + `_throttle_delay_s`), это следующий шаг, а не сегодняшний. + """ + global _saturation_rejected, _saturation_reported_at + + _saturation_rejected += 1 + now = time.monotonic() + since_prev = None if _saturation_reported_at is None else now - _saturation_reported_at + if since_prev is None or since_prev >= _SATURATION_REPORT_WINDOW_S: + rejected, _saturation_rejected = _saturation_rejected, 0 + _saturation_reported_at = now + logger.error( + "login rejected: password verify saturated — %d отказов, " + "с прошлой записи %s с, последний ip=%s", + rejected, + "—" if since_prev is None else f"{since_prev:.1f}", + ip, + ) + schedule_event( + event_type="login_verify_saturated", + username="", + ip=ip, + path="/api/v1/auth/login", + method="POST", + payload={ + "rejected": rejected, + # Считается ДО сдвига `_saturation_reported_at` — иначе всегда 0. + "since_prev_s": None if since_prev is None else round(since_prev, 1), + }, + ) + + # Retry-After 1с — порядок времени одной сверки, не окно соседнего + # `_LOGIN_LIMITER`. Ответ ОДИН И ТОТ ЖЕ для любого имени: отказ приходит до + # сверки и потому ничего не сообщает о том, существует ли учётка. + return HTTPException( + status_code=429, + detail="слишком много попыток входа, попробуйте позже", + headers={"Retry-After": "1"}, + ) + + async def _reject_invalid_credentials( db: Session, username: str, ip: str, user_agent: str | None ) -> HTTPException: @@ -256,6 +375,16 @@ async def login( headers={"Retry-After": str(int(retry_after) + 1)}, ) + # Гейт насыщения — ДО выборки из реестра (#2715). Заведомо отклоняемый + # запрос не берёт соединение из пула и не делает SELECT по имени: под + # насыщением это была бы единственная работа на пути отказа, а значит и + # единственное, чьё время зависит от существования учётки — bcrypt, который + # эту разницу ровняет, до отказанного запроса не доходит вовсе. Решение + # всё равно остаётся за `verify_password_bounded` ниже (тот же предикат, + # `except` под ним никуда не делся) — здесь только экономия похода в базу. + if verify_slots_saturated(ip): + raise _saturated_429(ip) + user = get_user_by_username(db, body.username) hash_to_check = ( user["password_hash"] @@ -274,17 +403,11 @@ async def login( password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip) except PasswordVerifyOverloadedError: # Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя — - # ждущий держит соединение к БД. Отказ ОДИНАКОВ для любого имени и - # случается ДО сверки, поэтому оракулом существования учётки не служит и - # бюджет неудач по имени не тратит (это не попытка входа: пароль не - # проверялся). Retry-After 1с — порядок времени одной проверки, не окно - # соседнего `_LOGIN_LIMITER`. - logger.warning("login rejected: password verify saturated ip=%s", ip) - raise HTTPException( - status_code=429, - detail="слишком много попыток входа, попробуйте позже", - headers={"Retry-After": "1"}, - ) from None + # ждущий держит соединение к БД. Предчек выше сюда почти всё и отсекает, + # но авторитетен ИМЕННО ЭТОТ отказ, поэтому ветка остаётся. Ответ — + # тот же самый и с той же аргументацией, что у предчека: один helper, + # чтобы две ветки не разъехались (одинаковость 429 — часть защиты). + raise _saturated_429(ip) from None # Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние # доступа. Порядок несущий, а не стилистический: см. модульный docstring. diff --git a/tradein-mvp/backend/app/core/password.py b/tradein-mvp/backend/app/core/password.py index 2cec3ada..374d55a2 100644 --- a/tradein-mvp/backend/app/core/password.py +++ b/tradein-mvp/backend/app/core/password.py @@ -123,6 +123,32 @@ def _per_key_slot_cap() -> int: return max(1, settings.login_password_verify_max_inflight // 2) +def verify_slots_saturated(key: str) -> bool: + """Тот же предикат, по которому отказывает `verify_password_bounded`, но БЕЗ взятия слота. + + Нужен вызывающему ровно затем, чтобы отказать ДО похода в БД (#2715). Гейт + стоял ПОСЛЕ выборки пользователя, и каждый заведомо отклоняемый запрос всё + равно брал соединение из пула и делал SELECT по имени — тогда, когда система + уже перегружена. Хуже того, под насыщением эта выборка оставалась + ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, который ровняет время ответа + для существующего и несуществующего имени, ниже по течению и до него не + доходит, так что разницу «строка найдена / не найдена» ничто не маскировало. + + Предчек, а не решение: авторитетная проверка остаётся внутри + `verify_password_bounded` — она зовёт ЭТУ ЖЕ функцию, так что разъехаться + двум условиям нечем, и инвариант «одна точка выноса = одна точка учёта» + цел (слот здесь не резервируется и не отдаётся). + + Учитывает и общий потолок, и долю на ключ (#2714) — иначе предчек не + покрывал бы главный случай: при флуде с ОДНОГО адреса первым упирается + именно доля, и большинство отказов снова ходило бы в базу. + """ + return ( + _verify_inflight >= settings.login_password_verify_max_inflight + or _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap() + ) + + async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool: """`verify_password`, унесённая с событийного цикла И с сознательным потолком темпа (#2665). @@ -193,9 +219,10 @@ async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool: """ global _verify_inflight - if _verify_inflight >= settings.login_password_verify_max_inflight: - raise PasswordVerifyOverloadedError - if _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap(): + # АВТОРИТЕТНАЯ проверка. Вызывающий может спросить то же самое заранее + # (`verify_slots_saturated`, #2715), но решение принимается здесь и только + # здесь — предчек экономит поход в БД, а не заменяет этот отказ. + if verify_slots_saturated(key): raise PasswordVerifyOverloadedError loop = asyncio.get_running_loop() diff --git a/tradein-mvp/backend/tests/test_audit_api.py b/tradein-mvp/backend/tests/test_audit_api.py index 78ec6684..a3226678 100644 --- a/tradein-mvp/backend/tests/test_audit_api.py +++ b/tradein-mvp/backend/tests/test_audit_api.py @@ -53,6 +53,39 @@ def test_days_param_uses_cast_as_int() -> None: assert "CAST(:days AS int)" in _AUDIT_SRC +def test_every_group_by_username_filters_out_the_nameless() -> None: + """Каждая выборка «по аккаунтам» отбрасывает строки с пустым именем (#2715). + + Пустое имя пишет `login_verify_saturated`: отказ по насыщению случается ДО + того, как мы посмотрели на присланное имя, и записать его нельзя — иначе + атакующий набивал бы аудит строками с любым именем на выбор. Но аккаунтом + такая строка от этого не становится: без фильтра она встаёт ПЕРВОЙ в списке + (её `last_seen_at` — момент атаки), даёт фантома в `count(DISTINCT + username)`, а раскрытие уходит в `/audit/accounts/{username}` с + `min_length=1` — то есть в ошибку. + + То же и со счётчиками уникальных: `count(DISTINCT username)` считал бы + безымянного за человека, и первая же атака НАВСЕГДА добавила бы +1 к числу + пользователей (строка остаётся в таблице). `NULLIF(username, '')` роняет её + в NULL, который `count(DISTINCT)` не считает. Сами события при этом из + `total_events` не исчезают — они события, просто не люди. + + Сравнение ЧИСЛОМ, а не поиском подстроки: так сторож ловит и НОВУЮ выборку, + добавленную без фильтра, а не только сегодняшние. На проде пустых имён + сейчас 0 из 3365 строк — то есть это ново. + """ + grouped = _AUDIT_SRC.count("GROUP BY username") + filtered = _AUDIT_SRC.count("WHERE username <> ''") + assert grouped == filtered, ( + f"{grouped} выборок GROUP BY username, из них с фильтром {filtered} — " + "безымянная строка попадёт в список аккаунтов" + ) + assert "count(DISTINCT username)" not in _AUDIT_SRC, ( + "count(DISTINCT username) считает безымянные события за людей — " + "нужен count(DISTINCT NULLIF(username, ''))" + ) + + # --------------------------------------------------------------------------- # Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc. # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 090b9422..17b0930e 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -32,6 +32,7 @@ in-memory fake DB standing in for the identity registry: from __future__ import annotations import asyncio +import logging import os import re import time @@ -259,6 +260,10 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: auth_mod.reset_cache_for_tests() auth_router._LOGIN_LIMITER._hits.clear() auth_router._USERNAME_FAIL_LIMITER._hits.clear() + # Агрегатор отказов по насыщению (#2715) — тоже глобал процесса: без сброса + # недосчитанные отказы одного теста всплывают в записи другого. + monkeypatch.setattr(auth_router, "_saturation_rejected", 0) + monkeypatch.setattr(auth_router, "_saturation_reported_at", None) monkeypatch.setattr(config.settings, "auth_mode", "dual") # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже # если предыдущий переключался на `auth`. @@ -958,6 +963,183 @@ async def test_flood_from_one_ip_leaves_login_open_for_another_ip( ) +# --------------------------------------------------------------------------- +# #2715 — отказ по насыщению: до похода в БД и со следом, который не выселяет лог +# --------------------------------------------------------------------------- + + +def _saturate_verify_slots(monkeypatch: pytest.MonkeyPatch) -> None: + """Слоты сверки заняты — снаружи ровно то же, что живой флуд, но без гонок. + + Именно счётчик, а не мок `verify_password_bounded`: проверяем настоящий + предикат отказа (`verify_slots_saturated` читает этот же глобал), а не + собственную заглушку. + """ + monkeypatch.setattr(password_mod, "_verify_inflight", 999) + + +def test_saturated_login_answers_before_touching_the_registry( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Под насыщением отказ приходит ДО выборки пользователя (#2715). + + Гейт стоял после SELECT'а, и каждый заведомо отклоняемый запрос всё равно + брал соединение из пула — тогда, когда система уже перегружена. Хуже того, + эта выборка оставалась ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, ровняющий + время ответа для существующего и несуществующего имени, до отказанного + запроса не доходит вовсе, так что разницу маскировать было нечем. + + Мерим не тайминг (в CI флейкует), а сам факт похода в реестр — и заодно + побайтовую одинаковость ответа для живого и выдуманного имени. + """ + store.add_user("alice", hash_password("Secret123!"), role="employee") + _capture_events(monkeypatch) + + lookups: list[str] = [] + real_lookup = auth_router.get_user_by_username + + def _spy(db: Any, username: str) -> Any: + lookups.append(username) + return real_lookup(db, username) + + monkeypatch.setattr(auth_router, "get_user_by_username", _spy) + _saturate_verify_slots(monkeypatch) + + bodies = [] + for name in ("alice", "ghost"): + resp = client.post( + "/api/v1/auth/login", + json={"username": name, "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429, resp.text + assert resp.headers["Retry-After"] == "1" + bodies.append(resp.text) + + assert lookups == [], f"под насыщением всё-таки сходили в реестр: {lookups}" + # Существующее и несуществующее имя — неразличимы (#2571 на этом пути тоже). + assert bodies[0] == bodies[1] + + +def test_key_share_alone_also_answers_before_the_registry( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Долю на ключ предчек проверяет ТОЖЕ — и это главный случай, а не запасной. + + Соседний тест занимает ОБЩИЙ счётчик, а `or` в `verify_slots_saturated` + коротит на первой половине: выброси вторую — и тот тест останется зелёным. + Между тем при флуде с ОДНОГО адреса (#2714) общий потолок не выбирается + вовсе, первой упирается именно доля, и без неё в базу ходили бы почти все + отклонённые запросы. + """ + store.add_user("alice", hash_password("Secret123!"), role="employee") + _capture_events(monkeypatch) + + lookups: list[str] = [] + real_lookup = auth_router.get_user_by_username + + def _spy(db: Any, username: str) -> Any: + lookups.append(username) + return real_lookup(db, username) + + monkeypatch.setattr(auth_router, "get_user_by_username", _spy) + # Общий котёл (4) НЕ выбран: занято 2 из 4, и оба — одним адресом. Это ровно + # его доля (`_per_key_slot_cap` = 4 // 2), больше ему не дают. + monkeypatch.setattr(password_mod, "_verify_inflight", 2) + monkeypatch.setattr(password_mod, "_verify_inflight_by_key", {"203.0.113.5": 2}) + assert password_mod._per_key_slot_cap() == 2 # исходные условия теста + + flooder = client.post( + "/api/v1/auth/login", + json={"username": "alice", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert flooder.status_code == 429, flooder.text + assert lookups == [], f"доля исчерпана, а в реестр всё-таки сходили: {lookups}" + + # И тут же — доказательство, что предчек не отказывает всем подряд: с + # ДРУГОГО адреса свободные слоты есть, запрос идёт дальше, в реестр. + other = client.post( + "/api/v1/auth/login", + json={"username": "alice", "password": "wrong"}, + headers={"x-forwarded-for": "198.51.100.10"}, + ) + assert other.status_code == 401, other.text + assert lookups == ["alice"] + + +def test_saturation_is_reported_once_per_window_and_lands_in_audit( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch, caplog: Any +) -> None: + """Двадцать отказов — одна запись в лог и одно событие в аудит, со счётчиком. + + Строка на каждый отказ делила с остальным бэкендом `json-file max-size 20m × + max-file 3`: при флуде в сотни запросов в секунду 60 МБ прокручиваются за + минуты и выселяют ВСЕ остальные логи ровно во время атаки. Поэтому окно. + + А событие в `user_events` — потому что до #2715 инцидент не оставлял в + аудите ни строчки: событие неудачного входа тут не пишется намеренно + (пароль не проверялся, и трата бюджета неудач дала бы блокировку чужой + учётки насыщением) — значит нужен отдельный тип события, и он обязан + появляться независимо от того, ротировался лог или нет. + """ + # ЛИТЕРАЛ, а не арифметика от настройки: окно — компромисс «видно вовремя» + # против «не выселяет лог», и подъём его до минут прячет атаку целиком. + assert auth_router._SATURATION_REPORT_WINDOW_S == 1.0 + + events = _capture_events(monkeypatch) + _saturate_verify_slots(monkeypatch) + # Окно на весь тест — иначе медленный CI разбил бы 20 запросов на два окна + # и число записей стало бы функцией скорости раннера. + monkeypatch.setattr(auth_router, "_SATURATION_REPORT_WINDOW_S", 60.0) + caplog.set_level(logging.WARNING, logger="app.api.v1.auth") + + for i in range(20): + resp = client.post( + "/api/v1/auth/login", + json={"username": f"ghost{i}", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429, resp.text + + lines = [r for r in caplog.records if "saturated" in r.getMessage()] + assert len(lines) == 1, f"20 отказов дали {len(lines)} строк в логе — агрегации нет" + # ERROR, а не WARNING: бэкенд поднят с LoggingIntegration(event_level=ERROR) + # (app/main.py), и только с ERROR запись становится событием GlitchTip. + # Понижение уровня выключило бы канал молча — прецедент #2674. + assert lines[0].levelno == logging.ERROR + + saturated = [e for e in events if e["event_type"] == "login_verify_saturated"] + assert len(saturated) == 1, saturated + # Первый отказ отчитывается сразу (одиночная аномалия обязана быть видна), + # поэтому в первой записи он один — накопленное придёт следующей. + assert saturated[0]["payload"] == {"rejected": 1, "since_prev_s": None} + assert saturated[0]["ip"] == "203.0.113.5" + # Имя не пишем: отказ случился ДО того, как мы на него посмотрели, а запись + # присланного дала бы атакующему аудит-строки с любым именем на выбор. + assert saturated[0]["username"] == "" + + # Бюджет неудач по имени не тронут — иначе насыщением блокируют чужой вход. + assert [e for e in events if e["event_type"] == "login_failed"] == [] + assert not auth_router._USERNAME_FAIL_LIMITER._hits + + # Окно прошло — следующий отказ приносит НАКОПЛЕННОЕ, а не единицу. + monkeypatch.setattr(auth_router, "_saturation_reported_at", time.monotonic() - 61.0) + resp = client.post( + "/api/v1/auth/login", + json={"username": "ghost-last", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429 + saturated = [e for e in events if e["event_type"] == "login_verify_saturated"] + assert len(saturated) == 2 + assert saturated[1]["payload"]["rejected"] == 20, "счётчик за окно потерян" + # Без этого числа 20 отказов читались бы как «20 за секунду», хотя копились + # они минуту: хвост уезжает в запись, датированную моментом СЛЕДУЮЩЕГО + # отказа и подписанную ЕГО адресом — возможно, случайного соседа по NAT. + assert saturated[1]["payload"]["since_prev_s"] == pytest.approx(61.0, abs=1.0) + + # --------------------------------------------------------------------------- # POST /logout # --------------------------------------------------------------------------- From f0968c851374fe84f0dfc0775a3f5a1c1409bb9d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 14:35:32 +0000 Subject: [PATCH 058/130] =?UTF-8?q?test(tradein/auth):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=BE=20=D0=BF=D1=80=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=BD=D1=83=D1=8E=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D1=83=20=E2=80=94=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B6=D0=B5=D0=BC,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20(#2715)=20(#2735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/tests/test_password_call_sites.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_password_call_sites.py diff --git a/tradein-mvp/backend/tests/test_password_call_sites.py b/tradein-mvp/backend/tests/test_password_call_sites.py new file mode 100644 index 00000000..48e37a9d --- /dev/null +++ b/tradein-mvp/backend/tests/test_password_call_sites.py @@ -0,0 +1,110 @@ +"""Правило «из `async def` зови ТОЛЬКО ограниченную сверку» — проверяемое (#2715). + +Правило живёт в docstring `app/core/password.py`: синхронный `verify_password` +блокирует поток на ~282 мс (bcrypt cost 12), поэтому из кода приложения его +зовёт РОВНО ОДНА функция — `verify_password_bounded`, и она же единственная, +кто считает слоты (потолок темпа #2665 + доля на ключ #2714). + +Комментарий это правило не удерживает. Синхронная функция остаётся публичной и +импортируемой, и достаточно одной строчки `asyncio.to_thread(verify_password, +…)` в будущем коде, чтобы получить вынос в поток ВООБЩЕ БЕЗ учёта слотов: +внешне всё работает, вход отвечает быстро, а потолок перебора тихо исчезает. +Ревью такое ловит ровно до тех пор, пока помнит, что правило есть. + +Прецедент такого сторожа в репозитории: backend/tests/sql/test_auth_sql_migrations.py. + +ПОЧЕМУ AST, А НЕ GREP. `verify_password` упоминается в комментариях и docstring'ах +(app/api/v1/auth.py, app/core/config.py) — текстовый поиск краснел бы на них, и +сторож пришлось бы ослаблять исключениями до бессмысленности. AST видит только +ССЫЛКИ НА СИМВОЛ и ловит форму без скобок (`to_thread(verify_password, …)`), +которую `grep 'verify_password('` не поймал бы вовсе — то есть ровно ту, ради +которой сторож и написан. + +ЧЕГО СТОРОЖ НЕ ВИДИТ, и это записано тут, а не подразумевается: строкового +доступа (`getattr(mod, "verify_password")`) и обхода модуля целиком (прямой +`bcrypt.checkpw`). От НАМЕРЕННОГО обхода он не защищает и не может — только от +нечаянного, а нечаянный и есть частый случай. Обе непойманные формы закреплены +исполняемо (`test_detector_blind_spots_are_known`), чтобы «не ловим» было +проверенным фактом, а не обещанием в тексте. + +Без БД и без сети — только чтение файлов. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_APP_DIR = _BACKEND_ROOT / "app" +# Единственное место, которому синхронная сверка разрешена: там она и определена, +# и оттуда её забирает пул внутри `verify_password_bounded`. +_OWNER = _APP_DIR / "core" / "password.py" + + +def _references_verify_password(source: str) -> bool: + """Ссылается ли модуль на символ `verify_password` (в любой форме).""" + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Name) and node.id == "verify_password": + return True + if isinstance(node, ast.Attribute) and node.attr == "verify_password": + return True + if isinstance(node, ast.ImportFrom) and any( + alias.name == "verify_password" for alias in node.names + ): + return True + return False + + +def test_detector_actually_detects() -> None: + """Сторож обязан уметь краснеть — иначе он зелен вхолостую. + + Проверка на самого себя: пустой детектор (`return False`) прошёл бы все + файлы приложения и выглядел бы работающим сторожем ровно до первого + настоящего нарушения. + """ + # Формы, которые обязан ловить. + assert _references_verify_password("from app.core.password import verify_password") + assert _references_verify_password("asyncio.to_thread(verify_password, plain, hashed)") + assert _references_verify_password("password.verify_password(plain, hashed)") + assert _references_verify_password("ok = verify_password(plain, hashed)") + + # Формы, на которые краснеть НЕЛЬЗЯ (иначе сторож потребуют выключить). + assert not _references_verify_password("await verify_password_bounded(p, h, key=ip)") + assert not _references_verify_password('"""Зови verify_password только из пула."""') + assert not _references_verify_password("# verify_password тут только в комментарии") + + +def test_detector_blind_spots_are_known() -> None: + """Слепые зоны — зафиксированы, а не забыты. + + Обе формы обходят сторож НАМЕРЕННЫМ усилием: строковый доступ к атрибуту и + обход модуля целиком. Ловить их AST'ом можно было бы только ценой ложняков + (любой `getattr` с любой строкой, любой вызов bcrypt), а цена ложняка — + требование выключить сторож. Тест держит это знание исполняемым: захочет + однажды детектор их ловить — покраснеет здесь и заставит осознанно + переписать и этот тест, и текст модуля. + """ + assert not _references_verify_password('fn = getattr(password_mod, "verify_password")') + assert not _references_verify_password("bcrypt.checkpw(plain.encode(), hashed.encode())") + + +def test_sync_verify_password_is_called_from_one_place_only() -> None: + """В `app/` синхронную сверку не поминает никто, кроме её собственного модуля.""" + # Область сканирования жива. `rglob` по несуществующему каталогу не падает — + # отдаёт пусто, нарушителей ноль, сторож зелен НАВСЕГДА. Достаточно + # переложить этот файл в подкаталог tests/ (их уже восемь, и прецедент + # такого сторожа лежит именно в подкаталоге), чтобы `parents[1]` уехал. + assert _OWNER.exists(), f"область сканирования съехала: {_APP_DIR}" + + offenders = [ + str(path.relative_to(_BACKEND_ROOT)) + for path in sorted(_APP_DIR.rglob("*.py")) + if path != _OWNER and _references_verify_password(path.read_text(encoding="utf-8")) + ] + + assert offenders == [], ( + f"{offenders}: синхронный verify_password блокирует поток на ~282 мс и НЕ считает " + "слоты. Из кода приложения зови verify_password_bounded (app/core/password.py) — " + "она единственная точка выноса в пул и единственная точка учёта потолка" + ) From 4aec49f7fb64e0253043cbdd8847afaaffba93b5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 15:22:21 +0000 Subject: [PATCH 059/130] =?UTF-8?q?fix(tradein/yandex):=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C=20=D0=BE=D0=B1=D0=BE=D0=B3?= =?UTF-8?q?=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B5=20=D0=B1?= =?UTF-8?q?=D0=B5=D1=80=D1=91=D0=BC=20=D1=82=D0=BE,=20=D1=87=D1=82=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B3=D0=B0=D0=B5=D1=82=20=D0=B4=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B5=D1=82=D0=B8=20(#2674)=20(#2738)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/tasks/yandex_detail_backfill.py | 64 ++++++++++++++++++- .../tasks/test_yandex_detail_backfill.py | 63 +++++++++++++++++- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 16873d03..6538c7c8 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -17,6 +17,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток снапшота уедет в следующую ночь через NULL detail_enriched_at. +Почему брейкер срабатывал так часто (разобрано 2026-08-06, замеры в комментарии +у OFFER_URL_PATTERN): в очереди лежали карточки новостроек, у которых source_url +ведёт на сайт застройщика, а не на realty.yandex.ru/offer//. Парсер отвергает +такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они +пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон +целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может +разобрать, а размер отброшенного видно в counters.unenrichable_pending. + Why curl_cffi and not YandexDetailScraper.fetch_detail: fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS fingerprinting). On datacenter IPs Yandex returns captcha / shell-HTML @@ -43,10 +51,27 @@ from app.services import scrape_runs as runs_mod logger = logging.getLogger(__name__) __all__ = [ + "OFFER_URL_PATTERN", "YandexDetailBackfillResult", "run_yandex_detail_backfill", ] +# Условие, при котором обогащение этого объявления вообще возможно (#2723-класс). +# `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` и без него +# возвращает None ЕЩЁ ДО обращения к HTML (providers/yandex/detail.py:150) — то есть +# отказ предрешён регуляркой, а не капчей. +# +# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют +# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru, +# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из +# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer//. +# +# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один +# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон: +# 32 прогона из 53 закончились ровно так — attempted=5, enriched=0, 23 секунды. +# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим. +OFFER_URL_PATTERN = "/offer/[0-9]+" + @dataclass class YandexDetailBackfillResult: @@ -55,6 +80,7 @@ class YandexDetailBackfillResult: attempted: int = 0 enriched: int = 0 failed: int = 0 + unenrichable_pending: int = 0 duration_sec: float = field(default=0.0) def to_dict(self) -> dict[str, int]: @@ -62,6 +88,7 @@ class YandexDetailBackfillResult: "attempted": self.attempted, "enriched": self.enriched, "failed": self.failed, + "unenrichable_pending": self.unenrichable_pending, "duration_sec": int(self.duration_sec), } @@ -105,6 +132,9 @@ async def run_yandex_detail_backfill( # SNAPSHOT: single SELECT at start -- NOT re-selected in loop. # Priority: is_active DESC (active first), scraped_at DESC (newest first). + # Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает + # (см. комментарий у константы): в очередь не берём то, что заведомо + # непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером. snapshot = ( db.execute( text( @@ -114,23 +144,53 @@ async def run_yandex_detail_backfill( WHERE source = 'yandex' AND detail_enriched_at IS NULL AND source_url IS NOT NULL + AND source_url ~ CAST(:offer_url_pattern AS text) ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST LIMIT CAST(:batch_size AS int) """ ), - {"batch_size": batch_size}, + {"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN}, ) .mappings() .all() ) + # Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено + # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). + counters.unenrichable_pending = int( + db.execute( + text( + """ + SELECT count(*) + FROM listings + WHERE source = 'yandex' + AND detail_enriched_at IS NULL + AND source_url IS NOT NULL + AND source_url !~ CAST(:offer_url_pattern AS text) + """ + ), + {"offer_url_pattern": OFFER_URL_PATTERN}, + ).scalar_one() + ) + if counters.unenrichable_pending: + logger.info( + "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: " + "source_url ведёт не на карточку Яндекса (%s), парсер их отвергает " + "до сети", + run_id, + counters.unenrichable_pending, + OFFER_URL_PATTERN, + ) + if not snapshot: logger.info( "yandex_detail_backfill: run_id=%d -- no pending listings " "(detail_enriched_at IS NULL = 0), done", run_id, ) - runs_mod.mark_done(db, run_id, current_counters) + # to_dict(), а не current_counters: пустая очередь при непустом + # unenrichable_pending — самый важный случай этого счётчика. + runs_mod.mark_done(db, run_id, counters.to_dict()) return counters logger.info( diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py index ccd3d2af..2d9f320a 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py @@ -13,6 +13,7 @@ from __future__ import annotations import json import os +import re import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -50,11 +51,13 @@ def _make_snapshot(n: int) -> list[dict]: ] -def _mock_db(snapshot: list[dict]) -> MagicMock: - """Fake Session: first execute() returns snapshot via .mappings().all().""" +def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock: + """Fake Session: execute() отдаёт снапшот через .mappings().all(), а + .scalar_one() — размер отброшенной (непарсимой) части очереди.""" db = MagicMock() sel = MagicMock() sel.mappings.return_value.all.return_value = snapshot + sel.scalar_one.return_value = unenrichable db.execute.return_value = sel return db @@ -448,3 +451,59 @@ def test_save_detail_enrichment_rowcount_zero_returns_false() -> None: saved = save_detail_enrichment(db, listing_id=404, e=enrichment) assert saved is False + + +# --------------------------------------------------------------------------- +# Очередь не должна содержать того, что парсер отвергает до сети (2026-08-06) +# --------------------------------------------------------------------------- + +# Реальные source_url с прода (2026-08-06). Верх очереди на момент прогона 3300 +# состоял ровно из таких строк: 5 попыток, 5 parse-None, abort за 23 секунды. +_PROD_QUEUE_HEAD = [ + ("https://macroserver.ru/id/224566/", False), + ("https://prospect-federation.ru/flat/192", False), + ("https://macroserver.ru/id/7223953/", False), + ("https://strana.com/ekaterinburg/flat/1234", False), + ("https://realty.yandex.ru/offer/7416316701146842927/", True), + ("https://realty.yandex.ru/offer/7298311881327827251/", True), +] + + +@pytest.mark.asyncio +async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None: + """Снапшот-SELECT судит по тому же признаку, что и парсер, — сторожем, а не на слово. + + `YandexDetailScraper.parse` возвращает None по регулярке в URL, ещё не + заглянув в HTML. Строки шире этого условия гарантированно дают parse-None и + пачкой выбивают брейкер «5 подряд», обрывая ВЕСЬ прогон (32 прогона из 53 на + проде). Проверяем на одних и тех же прод-URL обе стороны + что отброшенное + посчитано, а не молча исчезло. + """ + from scraper_kit.providers.yandex.detail import YandexDetailScraper + + db = _mock_db([], unenrichable=3535) + runs = MagicMock() + session_cls, _session = _make_session_ctx([]) + + with ( + patch(_ASYNC_SESSION, session_cls), + patch(_RUNS, runs), + patch(_SETTINGS, _mock_settings()), + ): + result = await run_yandex_detail_backfill( + db, run_id=42, params={"batch_size": 10, "budget_sec": 60} + ) + + snapshot_call = db.execute.call_args_list[0] + assert "source_url ~ CAST(:offer_url_pattern AS text)" in str(snapshot_call.args[0]) + pattern = snapshot_call.args[1]["offer_url_pattern"] + + scraper = YandexDetailScraper() + for url, enrichable in _PROD_QUEUE_HEAD: + assert (re.search(pattern, url) is not None) is enrichable, url + if not enrichable: + # HTML тут любой: отказ предрешён до его разбора. + assert scraper.parse("сайт застройщика", url) is None, url + + assert result.unenrichable_pending == 3535 + assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535 From 0dc6f126302c28f05c61c774900a3072f171540d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 15:29:48 +0000 Subject: [PATCH 060/130] =?UTF-8?q?fix(tradein/avito):=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D0=B8=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D1=83,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D1=8B=D0=B5=D0=B4=D0=B0=D0=B5=D1=82=20=D0=B1?= =?UTF-8?q?=D1=8E=D0=B4=D0=B6=D0=B5=D1=82=20(#2674)=20(#2739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrape_runs.py | 13 ++- .../app/tasks/avito_detail_backfill.py | 81 +++++++++++++++++- .../tests/tasks/test_avito_detail_backfill.py | 85 +++++++++++++++++++ 3 files changed, 175 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 789be992..dd7a56cf 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -578,6 +578,7 @@ def mark_backfill_finished( *, source: str, aborted_by_blocks: bool = False, + fail_hint: str | None = None, ) -> None: """Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'. @@ -601,11 +602,19 @@ def mark_backfill_finished( `gone` (404 у avito) считается результатом наравне с `enriched`: прогон, который подтвердил снятие объявлений, работу сделал. + + `fail_hint` — самая частая причина отказа этого прогона (задача считает её сама, + см. avito_detail_backfill._failure_signature). Дописывается в текст статуса, + потому что «blocked=5, обогащено 0» не отвечает на единственный вопрос, ради + которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи + контейнера на этот вопрос отвечать не могут — они исчезают при пересоздании + контейнера, то есть на первом же деплое после ночного прогона. """ attempted = int(counters.get("attempted") or 0) enriched = int(counters.get("enriched") or 0) blocked = int(counters.get("blocked") or 0) produced = enriched + int(counters.get("gone") or 0) + hint = f"; причина: {fail_hint}" if fail_hint else "" if attempted == 0: mark_done(db, run_id, counters) @@ -614,7 +623,7 @@ def mark_backfill_finished( if blocked and (aborted_by_blocks or produced == 0): reason = ( f"backfill-honest-status: {source} остановлен блоками источника — " - f"blocked={blocked}, обогащено {enriched} из {attempted} попыток (#2674)" + f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)" ) logger.error("%s run_id=%d", reason, run_id) mark_banned(db, run_id, reason, counters) @@ -624,7 +633,7 @@ def mark_backfill_finished( reason = ( f"backfill-honest-status: {source} без результата — 0 обогащено из " f"{attempted} попыток (failed={counters.get('failed', 0)}, " - f"blocked={blocked}) (#2674)" + f"blocked={blocked}){hint} (#2674)" ) logger.error("%s run_id=%d", reason, run_id) mark_failed(db, run_id, reason, counters) diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 3805b8e0..fe4ed9c0 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -13,6 +13,13 @@ session path as the detail-phase of `run_avito_city_sweep` rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного блоками прогона — 'banned' (#2674, runs.mark_backfill_finished): работу он не доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at. + +Отказы, не являющиеся блоками, до 2026-08-06 брейкера не имели вовсе: прогоны +3-5 августа делали ~1600 попыток, получали 1600 отказов, ноль обогащений и +выедали весь бюджет (9000 с) вместе с 1600 запросами через единственный прокси. +Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина +отказа пишется в текст статуса прогона (_failure_signature) — иначе она живёт +только в логах контейнера, а те исчезают на первом же деплое. """ from __future__ import annotations @@ -20,7 +27,9 @@ from __future__ import annotations import asyncio import logging import random +import re import time +from collections import Counter from dataclasses import dataclass, field from urllib.parse import urlparse @@ -99,6 +108,38 @@ _OBLAST_AVITO_URL_PATTERNS = tuple( ) +# Причина отказа карточки без её URL: 1576 отказов одного прогона должны схлопнуться +# в ОДНУ строку, иначе перепись бесполезна. +_URL_IN_MESSAGE_RE = re.compile(r"https?://\S+") + + +def _failure_signature(exc: BaseException) -> str: + """Подпись причины отказа: тип исключения + текст без URL. + + Зачем (замер 2026-08-06): у прогонов 3 и 4 августа counters говорили + `attempted=1576, failed=1576, blocked=0` — и ничего больше. Кто отказал, + площадка или наш тракт, было видно ТОЛЬКО в логах контейнера, а тот + пересоздаётся на каждом деплое и уносит их с собой; в GlitchTip попадают + события уровня ERROR, а поштучные отказы — WARNING. Разница между этими + двумя диагнозами — разные владельцы задачи (#2686, #2698), поэтому она + обязана переживать перезапуск контейнера, то есть лежать в самом прогоне. + + Тип исключения — первый разряд диагноза (AvitoBlockedError = площадка + показала 403/firewall; сетевой класс curl_cffi = наш прокси-тракт; + ValueError = ответ пришёл, но не разобран), текст — второй. + """ + message = _URL_IN_MESSAGE_RE.sub("", str(exc)).strip() + return f"{type(exc).__name__}: {message}"[:160] if message else type(exc).__name__ + + +def _top_failure(census: Counter[str]) -> str | None: + """Самая частая причина отказа с её долей; None — отказов не было.""" + if not census: + return None + reason, hits = census.most_common(1)[0] + return f"{reason} ({hits} из {sum(census.values())})" + + @dataclass class AvitoDetailBackfillResult: """Counters for one backfill run.""" @@ -139,6 +180,8 @@ async def run_avito_detail_backfill( budget_sec: float -- wall-clock budget per run, default 3600s. request_delay_sec: float -- delay between listings, default 6.0s. max_consecutive_blocks: int -- abort threshold, default 5. + max_consecutive_failures: int -- порог обрыва по отказам-не-блокам, + default 25 (см. комментарий у чтения параметра ниже). Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> mark_backfill_finished (done / banned при блоках / failed при нуле, #2674); @@ -149,6 +192,13 @@ async def run_avito_detail_backfill( budget_sec = float(params.get("budget_sec", 3600)) request_delay_sec = float(params.get("request_delay_sec", 6.0)) max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5)) + # Брейкер на отказы-НЕ-блоки. Блоки свой брейкер имели с самого начала, отказы — + # нет, и это стоило трёх ночей подряд: 3-5 августа прогон делал ~1600 попыток, + # получал 1600 отказов, ноль обогащений и выедал весь бюджет 9000 с (плюс 1600 + # запросов через единственный прокси, #2638). Порог заметно выше блочного: пачка + # мёртвых карточек (404 → ValueError в curl-режиме) не должна обрывать здоровый + # прогон, а 25 отказов подряд без единого успеха — уже не невезение. + max_consecutive_failures = int(params.get("max_consecutive_failures", 25)) warm_batch = int(params.get("warm_batch", 500)) research_every = int(params.get("research_every", 50)) block_cooldown_sec = float(params.get("block_cooldown_sec", 30.0)) @@ -308,9 +358,13 @@ async def run_avito_detail_backfill( ) consecutive_blocks = 0 + consecutive_failures = 0 aborted_by_blocks = False do_sleep = False items_since_warm = 0 + # Перепись причин (блоки + отказы) — переживает пересоздание контейнера, + # в отличие от логов; см. _failure_signature. + failure_census: Counter[str] = Counter() for idx, row in enumerate(snapshot): # Budget guard @@ -429,8 +483,9 @@ async def run_avito_detail_backfill( if use_curl: items_since_warm += 1 consecutive_blocks = 0 + consecutive_failures = 0 - except AvitoListingGoneError: + except AvitoListingGoneError as gone_exc: # #2034: мёртвый листинг (404 / removed) — НЕ блок, НЕ failed. # Координатные дыры в lat-null очереди в основном dead-листинги; # browser-mode рендерит их «Ошибка 404» без item-view → раньше это @@ -440,6 +495,10 @@ async def run_avito_detail_backfill( # и не сбрасываем). Метим is_active=FALSE → листинг уходит из scope # (snapshot SELECT фильтрует is_active = TRUE) и не тратит фетчи впредь. counters.gone += 1 + # 404 — честный ответ площадки, значит тракт цел: серия отказов + # прерывается (блочный брейкер 404 не трогает — см. #2034). + consecutive_failures = 0 + failure_census[_failure_signature(gone_exc)] += 1 try: with db.begin_nested(): db.execute( @@ -481,6 +540,7 @@ async def run_avito_detail_backfill( except (AvitoBlockedError, AvitoRateLimitedError) as e: consecutive_blocks += 1 counters.blocked += 1 + failure_census[_failure_signature(e)] += 1 do_sleep = False logger.warning( "avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", @@ -538,12 +598,14 @@ async def run_avito_detail_backfill( exc_info=True, ) - except TimeoutError: + except TimeoutError as e: # asyncio.wait_for → TimeoutError (py3.12: asyncio.TimeoutError — alias). # Ловим ДО общего Exception (TimeoutError ⊂ OSError ⊂ Exception). Зависший # fetch отменён → листинг failed, переходим к следующему (loop не зависает, # run не zombie #1950). Не считаем soft-блоком: rotate не дёргаем. counters.failed += 1 + consecutive_failures += 1 + failure_census[_failure_signature(e)] += 1 logger.warning( "avito_detail_backfill: run_id=%d listing %s TIMEOUT (>%.0fs) -- skip", run_id, @@ -557,6 +619,8 @@ async def run_avito_detail_backfill( except Exception as e: counters.failed += 1 + consecutive_failures += 1 + failure_census[_failure_signature(e)] += 1 logger.warning( "avito_detail_backfill: run_id=%d listing %s failed: %s", run_id, @@ -568,6 +632,18 @@ async def run_avito_detail_backfill( except Exception: pass + if consecutive_failures >= max_consecutive_failures: + logger.error( + "avito_detail_backfill: run_id=%d ABORT -- %d отказов подряд без " + "единого успеха, частая причина: %s. enriched=%d attempted=%d", + run_id, + consecutive_failures, + _top_failure(failure_census) or "неизвестна", + counters.enriched, + counters.attempted, + ) + break + if counters.attempted % 25 == 0: current_counters = counters.to_dict() runs_mod.update_heartbeat(db, run_id, current_counters) @@ -580,6 +656,7 @@ async def run_avito_detail_backfill( current_counters, source="avito_detail_backfill", aborted_by_blocks=aborted_by_blocks, + fail_hint=_top_failure(failure_census), ) logger.info( "avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index a30a3760..4bc663d8 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -790,3 +790,88 @@ async def test_backfill_use_curl_block_cooldown_research_no_rebuild() -> None: mock_scraper.return_value._rotate_ip.assert_not_called() runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() + + +# --------------------------------------------------------------------------- +# Отказы-не-блоки: брейкер + перепись причин (2026-08-06) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_backfill_aborts_on_consecutive_failures_and_names_the_reason() -> None: + """Серия отказов без единого успеха обрывается, а причина попадает в статус прогона. + + Прод 3-5 августа: attempted≈1600, failed≈1600, blocked=0, enriched=0, весь + бюджет 9000 с и 1600 запросов через единственный прокси — и ни слова о том, + ЧТО именно отказало (поштучные отказы логируются WARNING, а логи контейнера + пропадают на первом деплое). Брейкера на отказы-не-блоки не было вовсе. + """ + snapshot = _make_snapshot(200) + db = _mock_db(snapshot) + runs = MagicMock() + # Тот же класс отказа, что видели у соседнего свипа в тот же день. + mock_fetch = AsyncMock( + side_effect=OSError( + "Failed to perform, curl: (56) CONNECT tunnel failed, response 502. " + "See https://curl.se/libcurl/c/libcurl-errors.html" + ) + ) + fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=True) + with ( + patch(_SETTINGS, fake_settings), + patch(_SESSION, return_value=AsyncMock()), + patch(_SCRAPER), + patch(_RUNS, runs), + patch(_FETCH, mock_fetch), + patch(_SLEEP, new_callable=AsyncMock), + ): + result = await run_avito_detail_backfill( + db, + run_id=77, + params={ + "batch_size": 200, + "budget_sec": 3600, + "max_consecutive_failures": 25, + }, + ) + + assert result.attempted == 25, "серия отказов обязана обрываться, а не выедать бюджет" + assert result.failed == 25 + assert result.blocked == 0 + + hint = runs.mark_backfill_finished.call_args.kwargs["fail_hint"] + assert hint is not None + assert "OSError" in hint # тип исключения = кому принадлежит отказ + assert "CONNECT tunnel failed" in hint + assert "25 из 25" in hint # доля, а не единичный пример + assert "https://curl.se" not in hint # URL вырезан, иначе 1600 «разных» причин + + +@pytest.mark.asyncio +async def test_backfill_success_resets_failure_streak() -> None: + """Успех между отказами обнуляет серию — здоровый прогон брейкер не трогает.""" + snapshot = _make_snapshot(5) + db = _mock_db(snapshot) + runs = MagicMock() + boom = ValueError("avito detail HTTP 500 for https://www.avito.ru/x") + # 2 отказа, успех, 2 отказа — при пороге 3 ни одна серия его не достигает. + mock_fetch = AsyncMock(side_effect=[boom, boom, MagicMock(), boom, boom]) + fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=True) + with ( + patch(_SETTINGS, fake_settings), + patch(_SESSION, return_value=AsyncMock()), + patch(_SCRAPER), + patch(_RUNS, runs), + patch(_FETCH, mock_fetch), + patch(_SAVE, return_value=True), + patch(_SLEEP, new_callable=AsyncMock), + ): + result = await run_avito_detail_backfill( + db, + run_id=78, + params={"batch_size": 5, "budget_sec": 3600, "max_consecutive_failures": 3}, + ) + + assert result.attempted == 5 + assert result.enriched == 1 + assert result.failed == 4 From c86a5378efd695f3a952e2f1eda3304367217714 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 15:41:20 +0000 Subject: [PATCH 061/130] =?UTF-8?q?feat(tradein/houses):=20=D0=B6=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D0=BB=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D1=80=D0=B0=D1=82=D0=B8=D0=BC=D1=8B=D0=BC?= =?UTF-8?q?=20(#2740)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/house_dedup_merge.py | 272 ++++++++++++-- .../backend/data/sql/230_house_merge_log.sql | 262 +++++++++++++ .../backend/tests/test_house_dedup_merge.py | 355 ++++++++++++++++-- 3 files changed, 822 insertions(+), 67 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/230_house_merge_log.sql diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index 330b66c0..0f244b97 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -92,6 +92,30 @@ BACKFILL (reduces recurrence): (same as 108) so the matching pipeline's Tier-1/Tier-2 finds the keeper next scrape and does not immediately re-split it. +MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230): + Every loser gets a row in `house_merge_log` written in the SAME transaction as the merge: + the full jsonb snapshot of the deleted row, the keeper's snapshot BEFORE the identity + carry-over, the ids of every child row whose FK moved, the full snapshots of every child row + a UNIQUE collision destroyed, plus the grounds — which pass, which cluster-key VALUE fired, + whether the geo guard was on, and the keeper↔loser distance in metres. + + This exists because the merge used to leave no restorable trace: losers were hard-deleted + with their children and the only record of «what went into what» was a log line, in a + container whose logs rotate faster than a day. A day after a run nobody could even NAME the + pairs, and the only rollback was restoring the whole database. + + Undo: `SELECT * FROM house_merge_undo(batch_id)` inside a transaction — restores the loser + rows, points the children back, re-inserts the destroyed children, and un-does the identity + carry-over on the keeper, reporting per record what it could and could not restore. + + NOTE the journal is deliberately NEUTRAL to the merge rule: it changes no cluster key, no + keeper rule and no guard. It only makes whatever the pass decides reversible — which is the + precondition for revisiting those decisions at all (#2690, #1772). + + distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That + asymmetry — merge allowed without a proximity check — was invisible in data before; now + «how many merges happened beyond N metres, on which key» is one query. + IDEMPOTENCY: Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the mapping is empty → every statement touches 0 rows → no-op. Re-running is safe. @@ -105,8 +129,10 @@ psycopg v3: all SQL uses CAST(:x AS type), never the colon-colon bound-param cas from __future__ import annotations +import json import logging import time +import uuid from dataclasses import dataclass, field from typing import Any @@ -260,7 +286,16 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: -- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon -- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per -- cluster) and for canon clusters where at most one side carries a fias. - SELECT id AS loser_id, keeper_id, norm_address + -- + -- cluster_key / distance_m are carried out of the mapping for the MERGE JOURNAL (#2690): + -- cluster_key records WHICH key value fired, distance_m how far apart the two rows were. + -- distance_m is computed even when the geo guard is OFF for this pass — that is precisely + -- the case where nothing else records the distance, and #2690 had no way to ask + -- «how many merges happened at distances the guard would have blocked» from data. + SELECT id AS loser_id, keeper_id, norm_address, cluster_key, + CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL + THEN ST_DistanceSphere(loser_geom, keeper_geom) + END AS distance_m FROM ranked WHERE rn > 1 AND id <> keeper_id{geo_guard} @@ -289,6 +324,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id_fk = m.keeper_id FROM _1772_dup_mapping m WHERE l.house_id_fk = m.loser_id + RETURNING m.loser_id, l.id AS child_id """, ), ( @@ -298,6 +334,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hph.house_id = m.loser_id + RETURNING m.loser_id, hph.id AS child_id """, ), ( @@ -307,6 +344,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hr.house_id = m.loser_id + RETURNING m.loser_id, hr.id AS child_id """, ), ( @@ -316,6 +354,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hrc.house_id = m.loser_id + RETURNING m.loser_id, hrc.id AS child_id """, ), ( @@ -325,6 +364,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE ev.house_id = m.loser_id + RETURNING m.loser_id, ev.id AS child_id """, ), # ── UNIQUE(ext_source, ext_id): delete colliding losers, re-point rest ───── @@ -340,6 +380,7 @@ _STEPS: list[tuple[str, str]] = [ AND hs2.ext_source = hs.ext_source AND hs2.ext_id = hs.ext_id ) + RETURNING hs.house_id AS loser_id, to_jsonb(hs.*) AS row_snapshot """, ), ( @@ -349,6 +390,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hs.house_id = m.loser_id + RETURNING m.loser_id, hs.id AS child_id """, ), # ── UNIQUE(normalized_address): delete colliding losers, re-point rest ───── @@ -363,6 +405,7 @@ _STEPS: list[tuple[str, str]] = [ WHERE haa2.house_id = m.keeper_id AND haa2.normalized_address = haa.normalized_address ) + RETURNING haa.house_id AS loser_id, to_jsonb(haa.*) AS row_snapshot """, ), ( @@ -372,6 +415,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE haa.house_id = m.loser_id + RETURNING m.loser_id, haa.id AS child_id """, ), # ── UNIQUE(house_id, source, room_count, prices_type, period, month_date) ── @@ -393,6 +437,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -402,6 +447,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hpd.house_id = m.loser_id + RETURNING m.loser_id, hpd.id AS child_id """, ), # ── UNIQUE(house_id): one evaluation per keeper ─────────────────────────── @@ -419,6 +465,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -428,6 +475,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hie.house_id = m.loser_id + RETURNING m.loser_id, hie.id AS child_id """, ), # ── UNIQUE(house_id, ext_item_id) ───────────────────────────────────────── @@ -445,6 +493,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -454,6 +503,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hs.house_id = m.loser_id + RETURNING m.loser_id, hs.id AS child_id """, ), # ── UNIQUE(house_id, audit_batch) ───────────────────────────────────────── @@ -471,6 +521,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -480,10 +531,98 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE ama.house_id = m.loser_id + RETURNING m.loser_id, ama.id AS child_id """, ), ] +# ── MERGE JOURNAL (#2690) ───────────────────────────────────────────────────── +# +# Every child of houses(id) except `listings` references it through a column named house_id; +# listings uses house_id_fk. The undo function reads the column name back out of the journal +# key ("таблица.колонка"), so this mapping is what makes the reverse UPDATE possible. +_FK_COLUMN = {"listings": "house_id_fk"} + +# The (table, column) pairs the _STEPS pipeline actually handles, derived FROM the steps so the +# set cannot drift away from them. Compared against pg_catalog before every merge — see +# _assert_all_fk_children_handled. +_HANDLED_CHILDREN: frozenset[tuple[str, str]] = frozenset( + (tbl, _FK_COLUMN.get(tbl, "house_id")) for tbl in {label.split("(")[0] for label, _ in _STEPS} +) + +# Live FK children of houses(id), read from the catalog rather than trusted from a comment. +_FK_CHILDREN_SQL = text( + """ + SELECT CAST(CAST(c.conrelid AS regclass) AS text) AS child_table, + a.attname AS fk_column + FROM pg_constraint c + JOIN unnest(c.conkey) AS k(attnum) ON true + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.confrelid = CAST('houses' AS regclass) + AND c.contype = 'f' + """ +) + +# One journal row per loser, written from the mapping BEFORE anything is mutated — so loser_row +# is the row as it stood, and keeper_before precedes the identity carry-over. +_JOURNAL_INSERT_SQL = text( + """ + INSERT INTO house_merge_log ( + batch_id, run_id, initiator, merge_pass, cluster_key, geo_guard, distance_m, + norm_address, loser_id, keeper_id, loser_row, keeper_before + ) + SELECT + CAST(:batch_id AS uuid), + CAST(:run_id AS bigint), + CAST(:initiator AS text), + CAST(:merge_pass AS text), + m.cluster_key, + CAST(:geo_guard AS boolean), + m.distance_m, + m.norm_address, + m.loser_id, + m.keeper_id, + to_jsonb(l.*), + to_jsonb(k.*) + FROM _1772_dup_mapping m + JOIN houses l ON l.id = m.loser_id + JOIN houses k ON k.id = m.keeper_id + """ +) + +# Child bookkeeping lands after the steps ran — only then is it known which rows moved and which +# were destroyed by a UNIQUE collision. +_JOURNAL_CHILDREN_SQL = text( + """ + UPDATE house_merge_log + SET children_repointed = CAST(:children_repointed AS jsonb), + children_deleted = CAST(:children_deleted AS jsonb) + WHERE batch_id = CAST(:batch_id AS uuid) + AND loser_id = CAST(:loser_id AS bigint) + """ +) + + +def _assert_all_fk_children_handled(db: Session) -> None: + """Fail the merge if houses(id) gained an FK child the _STEPS pipeline does not handle. + + This is what makes the journal's promise true rather than merely documented. An unhandled + child is not a cosmetic gap: 9 of the 11 FKs are ON DELETE CASCADE, so `DELETE FROM houses` + would destroy its rows silently — no re-point step touches them, no RETURNING records them, + and the journal would claim a complete snapshot it does not have. Migration 133 already + broke on prod for exactly this (a missed child); there the failure was loud. Here it would + be silent, which is worse. Aborting the transaction costs one skipped merge cycle. + """ + live = {(r.child_table, r.fk_column) for r in db.execute(_FK_CHILDREN_SQL).all()} + unhandled = live - _HANDLED_CHILDREN + if unhandled: + raise RuntimeError( + "merge_duplicate_houses: houses(id) has FK children the merge does not handle: " + f"{sorted(unhandled)}. Their rows would be CASCADE-deleted without a journal entry. " + "Add a re-point step to _STEPS (and its RETURNING) before merging again." + ) + + # Delete the loser houses — all FK children are re-pointed or CASCADE by now. _DELETE_LOSERS_SQL = text( """ @@ -607,14 +746,20 @@ def _run_merge_pass( *, build_sql: Any, pass_label: str, + geo_guard: bool, + batch_id: str, + run_id: int | None, + initiator: str, result: DedupMergeResult, ) -> None: """Run ONE merge pass (fias- or canon-key) inside the caller's open transaction. - Builds a fresh loser→keeper mapping for this pass's cluster key, re-points every FK child - (UNIQUE-collision-safe), carries identity/enrichment onto the keeper, deletes the losers and - backfills sources/aliases. Accumulates counters onto `result`. NEVER commits/rolls back — the - caller owns the single transaction wrapping both passes. + Builds a fresh loser→keeper mapping for this pass's cluster key, writes the MERGE JOURNAL + (#2690), re-points every FK child (UNIQUE-collision-safe), carries identity/enrichment onto + the keeper, deletes the losers and backfills sources/aliases. Accumulates counters onto + `result`. NEVER commits/rolls back — the caller owns the single transaction wrapping both + passes, which is also what makes the journal atomic with the merge: there is no ordering in + which the rows vanish but the journal entry does not land (and dry_run rolls back both). """ # Fresh mapping for this pass. ON COMMIT DROP only fires at txn end, so drop the temp table # explicitly — the second pass must rebuild the same-named table within the one transaction. @@ -623,8 +768,8 @@ def _run_merge_pass( mapping = db.execute( text( - "SELECT loser_id, keeper_id, norm_address FROM _1772_dup_mapping " - "ORDER BY keeper_id, loser_id" + "SELECT loser_id, keeper_id, norm_address, cluster_key, distance_m " + "FROM _1772_dup_mapping ORDER BY keeper_id, loser_id" ) ).all() if not mapping: @@ -634,32 +779,73 @@ def _run_merge_pass( result.losers_deleted += len(mapping) result.clusters_merged += len({row.keeper_id for row in mapping}) - # Audit log: every loser→keeper move with its address, for traceability. + # JOURNAL, phase 1 — snapshot loser + keeper BEFORE any statement mutates them. + db.execute( + _JOURNAL_INSERT_SQL, + { + "batch_id": batch_id, + "run_id": run_id, + "initiator": initiator, + "merge_pass": pass_label, + "geo_guard": geo_guard, + }, + ) + + # Container logs rotate faster than a day (#2690), so this line is a convenience, not the + # record — house_merge_log is. Distance is logged too: it is the one number that says + # whether a merge would have survived the geo guard. for row in mapping: logger.info( - "merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r", + "merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r " + "distance_m=%s batch=%s", pass_label, row.loser_id, row.keeper_id, row.norm_address, + "n/a" if row.distance_m is None else f"{row.distance_m:.0f}", + batch_id, ) + # Per-loser child bookkeeping, collected from each step's RETURNING: survivors by id (the + # rows are intact, only their FK moved), destroyed rows by full snapshot (nothing else is + # left of them). + repointed: dict[int, dict[str, list[int]]] = {} + deleted: dict[int, dict[str, list[Any]]] = {} + for label, sql in _STEPS: - res = db.execute(text(sql)) - rowcount = res.rowcount or 0 - if label == "listings": - result.listings_repointed += rowcount - elif label.endswith("(collision-delete)") or label.endswith("(dedup)"): + rows = db.execute(text(sql)).all() + rowcount = len(rows) + table = label.split("(")[0] + if label.endswith("(collision-delete)") or label.endswith("(dedup)"): result.children_deleted += rowcount - elif label.endswith("(re-point)") or label in ( - "house_placement_history", - "house_reviews", - "house_reliability_checks", - "external_valuations", - ): - result.children_repointed += rowcount + for r in rows: + deleted.setdefault(r.loser_id, {}).setdefault(table, []).append(r.row_snapshot) + else: + key = f"{table}.{_FK_COLUMN.get(table, 'house_id')}" + for r in rows: + repointed.setdefault(r.loser_id, {}).setdefault(key, []).append(r.child_id) + if label == "listings": + result.listings_repointed += rowcount + else: + result.children_repointed += rowcount logger.debug("merge_duplicate_houses: pass=%s step=%s rows=%d", pass_label, label, rowcount) + # JOURNAL, phase 2 — attach the child bookkeeping to the rows written in phase 1. + touched = sorted(set(repointed) | set(deleted)) + if touched: + db.execute( + _JOURNAL_CHILDREN_SQL, + [ + { + "batch_id": batch_id, + "loser_id": loser_id, + "children_repointed": json.dumps(repointed.get(loser_id, {})), + "children_deleted": json.dumps(deleted.get(loser_id, {}), default=str), + } + for loser_id in touched + ], + ) + # Carry identity/enrichment onto the keeper BEFORE the losers vanish, then delete + backfill. db.execute(_CARRY_OVER_IDENTITY_SQL) db.execute(_DELETE_LOSERS_SQL) @@ -667,7 +853,13 @@ def _run_merge_pass( db.execute(_BACKFILL_ALIASES_SQL) -def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]: +def merge_duplicate_houses( + db: Session, + *, + dry_run: bool = False, + run_id: int | None = None, + initiator: str = "manual", +) -> dict[str, int]: """Cluster houses by fias UUID, then by canonical address, merging dups onto one keeper. Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job: @@ -680,16 +872,41 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an empty mapping in each pass → every statement is a 0-row no-op. + Every deleted row is journaled to house_merge_log in the SAME transaction (#2690), so a + merge is reversible via house_merge_undo(batch_id); the batch_id is returned in the log line + and stored on every journal row of this call. + Returns the counter dict (DedupMergeResult.to_counters()). """ start = time.monotonic() result = DedupMergeResult(dry_run=dry_run) + batch_id = str(uuid.uuid4()) try: + # Refuse to merge at all if some FK child would be CASCADE-destroyed unjournaled. + _assert_all_fk_children_handled(db) # Pass 1: cluster by the ФИАС building UUID (runs first — most precise building identity). - _run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL_FIAS, pass_label="fias", result=result) + _run_merge_pass( + db, + build_sql=_BUILD_MAPPING_SQL_FIAS, + pass_label="fias", + geo_guard=False, + batch_id=batch_id, + run_id=run_id, + initiator=initiator, + result=result, + ) # Pass 2: cluster by canonical address, with the cross-fias anti-over-merge guard. - _run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL, pass_label="canon", result=result) + _run_merge_pass( + db, + build_sql=_BUILD_MAPPING_SQL, + pass_label="canon", + geo_guard=True, + batch_id=batch_id, + run_id=run_id, + initiator=initiator, + result=result, + ) if result.losers_deleted == 0: # Clean table — both passes empty. Roll back (we only opened temp tables). @@ -716,12 +933,15 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i db.commit() logger.info( "merge_duplicate_houses: COMMITTED clusters=%d losers=%d " - "listings_repointed=%d children_deleted=%d children_repointed=%d", + "listings_repointed=%d children_deleted=%d children_repointed=%d " + "batch_id=%s (undo: SELECT * FROM house_merge_undo('%s'))", result.clusters_merged, result.losers_deleted, result.listings_repointed, result.children_deleted, result.children_repointed, + batch_id, + batch_id, ) except Exception: logger.exception("merge_duplicate_houses: FAILED — rolling back") @@ -761,7 +981,7 @@ def run_house_dedup_merge(db: Session, *, run_id: int, params: dict) -> dict[str } try: runs_mod.update_heartbeat(db, run_id, counters) - counters = merge_duplicate_houses(db, dry_run=dry_run) + counters = merge_duplicate_houses(db, dry_run=dry_run, run_id=run_id, initiator="schedule") runs_mod.mark_done(db, run_id, counters) logger.info( "run_house_dedup_merge: run_id=%d DONE clusters=%d losers=%d dry_run=%s", diff --git a/tradein-mvp/backend/data/sql/230_house_merge_log.sql b/tradein-mvp/backend/data/sql/230_house_merge_log.sql new file mode 100644 index 00000000..bf55ec6b --- /dev/null +++ b/tradein-mvp/backend/data/sql/230_house_merge_log.sql @@ -0,0 +1,262 @@ +-- 230_house_merge_log.sql +-- Журнал слияний домов + обратная операция (#2690). +-- +-- WHY: +-- `house_dedup_merge` — НЕ спящая идея, а живой деструктивный проход: расписание +-- `house_dedup_merge` на проде enabled=true, dry_run=false, такт 7 дней. Шесть прогонов +-- с 2026-06-27 уже удалили 119 строк `houses` (счётчики losers_deleted в scrape_runs: +-- 2/39/31/9/6/32). Единственным следом «кто в кого» была строка `logger.info` в контейнере, +-- а логи ротируются быстрее суток. То есть **уже сегодня** нельзя назвать, какой дом в какой +-- свернули 1 августа, — не говоря о том, чтобы вернуть. +-- +-- Пока этого журнала нет, любой разговор о расширении ключа схлопывания (#2690, #1772) +-- ведётся без права на ошибку: единственный откат — restore всей БД на момент до прогона, +-- т.е. выброс недели сбора. Журнал снимает это условие: слияние становится обратимым, +-- и вопрос о ключе можно пересматривать, а не решать «навсегда». +-- +-- Правку НЕ следует читать как одобрение текущего ключа/победителя/гео-стража. Она к ним +-- НЕЙТРАЛЬНА: ни ключ, ни правило выбора победителя, ни гео-страж здесь не меняются. +-- Меняется только одно — теперь есть что откатить. +-- +-- WHAT (одна строка = один проигравший дом): +-- merge_pass / cluster_key / geo_guard / distance_m — ОСНОВАНИЕ слияния. Это не косметика: +-- ровно этих полей не хватило в #2690, чтобы ответить на вопрос «сколько слияний прошло +-- на расстояниях, которые гео-страж заблокировал бы» по ДАННЫМ, а не по ревью. distance_m +-- пишется всегда, даже когда страж для прохода выключен (fias-проход) — тогда он и есть +-- единственная запись о том, насколько далеко разъехались объединённые дома. +-- loser_row — ПОЛНЫЙ jsonb-снимок удаляемой строки (`to_jsonb(h.*)`, все 86 колонок). +-- Ссылка на удалённую строку бесполезна, поэтому хранится содержимое. Снимок целиком, +-- а не список полей: проверено, что `jsonb_populate_record(NULL::houses, loser_row)` +-- восстанавливает строку побайтово, включая PostGIS-geom (to_jsonb отдаёт её GeoJSON'ом, +-- populate_record разбирает обратно входной функцией типа). Побочная выгода: новая +-- колонка в `houses` попадает в снимок и в откат САМА, без правки этой миграции. +-- keeper_before — снимок ПОБЕДИТЕЛЯ до переноса метаданных. Нужен, потому что слияние не +-- только удаляет проигравшего: `_CARRY_OVER_IDENTITY_SQL` дозаполняет победителю NULL-поля +-- идентичности (fias/кадастр/ГАР/DaData) значениями проигравшего. Без этого снимка откат +-- вернул бы дом, но оставил бы его ФИАС на победителе — и следующий же fias-проход слил +-- бы их обратно. +-- children_repointed — {"таблица.колонка": [id, ...]}. Дочерние строки ПЕРЕЖИЛИ слияние, +-- у них сменилась только ссылка, поэтому хранятся id, а не содержимое (иначе одни +-- listings с их raw-payload'ом дали бы ~7 КБ на строку вместо ~8 байт на id). +-- children_deleted — {"таблица": [{строка целиком}, ...]}. Дочерние строки, которые проход +-- УДАЛИЛ из-за коллизии по UNIQUE. Их содержимое уничтожено, id недостаточно — только +-- полный снимок. Таких таблиц шесть (см. _STEPS), строки мелкие. +-- batch_id — один вызов merge_duplicate_houses() (оба прохода). Единица отката. +-- run_id / initiator — кто инициировал: scrape_runs.id для расписания, NULL для ручного. +-- +-- НАМЕРЕННО БЕЗ ВНЕШНИХ КЛЮЧЕЙ на houses(id) и scrape_runs(id): +-- журнал обязан ПЕРЕЖИВАТЬ строки, которые описывает. loser_id указывает на заведомо +-- удалённый дом. keeper_id — на дом, который сам может быть слит следующим прогоном; FK +-- с CASCADE стёр бы историю ровно тогда, когда она нужнее всего, а FK без CASCADE +-- заблокировал бы слияние. То же с run_id: чистка scrape_runs не должна трогать журнал. +-- +-- ОБЪЁМ (замерено на проде 2026-08-06): +-- 9 571 дом, средняя строка houses в jsonb 2 581 Б. Строка журнала ≈ loser_row 2.5 КБ + +-- keeper_before 2.5 КБ + списки id (в среднем 27.9 дочерних строк на дом × ~8 Б) ≈ 5.3 КБ. +-- Наблюдаемый темп — 20 слияний в неделю (119 за 6 прогонов) ≈ 106 КБ/нед ≈ 5.5 МБ/год. +-- Ближайший прогон (замер тем же выражением, что и код): 93 проигравших ≈ 0.5 МБ. +-- Абсолютный потолок, если схлопнуть вообще все дома: 9 571 × 5.3 КБ ≈ 50 МБ против 23 МБ +-- самой таблицы houses. +-- +-- RETENTION: НЕ НУЖЕН, сознательно. Потолок роста — двузначные мегабайты, то есть дешевле +-- любой процедуры чистки; а журнал слияний — это ровно то, что удалять не хочется: его +-- ценность в том, что он отвечает на вопрос «что было год назад», когда логов давно нет. +-- Если объём когда-нибудь станет проблемой, удалять надо не строки, а тяжёлые снимки +-- (loser_row/keeper_before → NULL) у записей старше N лет, сохранив соответствие +-- loser→keeper: оно весит байты и именно оно нужно дольше всего. +-- +-- Dependencies: 002_core_tables.sql (houses), 135_scrape_schedules_seed_house_dedup_merge.sql +-- Пишется в ТОЙ ЖЕ транзакции, что и слияние (см. house_dedup_merge._run_merge_pass) — +-- разрыв «слияние прошло, запись не легла» невозможен по построению; dry_run откатывает и то, +-- и другое вместе. + +BEGIN; + +CREATE TABLE IF NOT EXISTS house_merge_log ( + id bigserial PRIMARY KEY, + merged_at timestamptz NOT NULL DEFAULT now(), + batch_id uuid NOT NULL, + run_id bigint, + initiator text NOT NULL, + merge_pass text NOT NULL, + cluster_key text NOT NULL, + geo_guard boolean NOT NULL, + distance_m double precision, + norm_address text, + loser_id bigint NOT NULL, + keeper_id bigint NOT NULL, + loser_row jsonb NOT NULL, + keeper_before jsonb NOT NULL, + children_repointed jsonb NOT NULL DEFAULT '{}'::jsonb, + children_deleted jsonb NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_house_merge_log_loser ON house_merge_log (loser_id); +CREATE INDEX IF NOT EXISTS idx_house_merge_log_keeper ON house_merge_log (keeper_id); +CREATE INDEX IF NOT EXISTS idx_house_merge_log_batch ON house_merge_log (batch_id); + +COMMENT ON TABLE house_merge_log IS + 'Журнал слияний домов (#2690): одна строка = один проигравший дом, удалённый проходом ' + 'house_dedup_merge. Пишется в ТОЙ ЖЕ транзакции, что и слияние. Содержит полный снимок ' + 'удалённой строки и перечень перенесённых/удалённых дочерних строк — достаточно, чтобы ' + 'назвать поимённо, что во что свернули, и вернуть обратно (house_merge_undo). Намеренно ' + 'БЕЗ FK на houses/scrape_runs: журнал переживает строки, которые описывает. Retention нет.'; + +COMMENT ON COLUMN house_merge_log.cluster_key IS + 'ЗНАЧЕНИЕ ключа, по которому дома попали в один кластер («addr:вайнера66» / «fias:»), ' + 'а не имя ключа — по нему видно, какое именно совпадение сработало.'; +COMMENT ON COLUMN house_merge_log.geo_guard IS + 'Был ли для этого прохода включён гео-страж 250 м. false = слияние разрешено БЕЗ проверки ' + 'близости; вместе с distance_m это и есть аудит основания (#2690).'; +COMMENT ON COLUMN house_merge_log.distance_m IS + 'ST_DistanceSphere между победителем и проигравшим на момент слияния; NULL = у одной из ' + 'сторон не было geom. Пишется ВСЕГДА, в том числе когда гео-страж выключен.'; +COMMENT ON COLUMN house_merge_log.loser_row IS + 'to_jsonb() удалённой строки houses целиком. Восстановление: ' + 'INSERT INTO houses SELECT r.* FROM jsonb_populate_record(NULL::houses, loser_row) r.'; +COMMENT ON COLUMN house_merge_log.keeper_before IS + 'Снимок победителя ДО переноса метаданных с проигравшего (COALESCE-дозаполнение полей ' + 'идентичности). Без него откат вернул бы дом, но оставил его ФИАС/кадастр на победителе.'; +COMMENT ON COLUMN house_merge_log.children_repointed IS + '{"таблица.колонка": [id, ...]} — дочерние строки, у которых слияние сменило ссылку ' + 'loser→keeper. Строки целы, поэтому хранятся id: откат возвращает ссылку обратно.'; +COMMENT ON COLUMN house_merge_log.children_deleted IS + '{"таблица": [{строка целиком}, ...]} — дочерние строки, УДАЛЁННЫЕ проходом из-за коллизии ' + 'по UNIQUE с победителем. Содержимое уничтожено, поэтому хранится снимок, а не id.'; + +-- ── Обратная операция ──────────────────────────────────────────────────────── +-- +-- Откат одного батча (или его части) по журналу. Транзакционен: вызывающий сам решает +-- COMMIT/ROLLBACK, увидев отчёт. Возвращает СТРОКУ НА КАЖДУЮ запись журнала со статусом — +-- в том числе «не смог», потому что молчаливо-успешный откат хуже отсутствующего. +-- +-- Порядок внутри одной записи важен: сначала воскресить дом (на него ссылаются дети), потом +-- вернуть ссылки детей, потом вернуть удалённых детей, потом снять перенос метаданных с +-- победителя. Записи батча обходятся в обратном порядке (id DESC) — если дом A слили в B, +-- а B потом в C, разматывать надо с конца. +-- +-- ИЗВЕСТНЫЕ ГРАНИЦЫ (сознательные, отражены в статусе): +-- * дочерняя строка, удалённая по коллизии, может не вернуться: место в UNIQUE-ключе занято +-- строкой победителя. ON CONFLICT DO NOTHING + счётчик в статусе, а не тихая потеря; +-- * backfill-строки house_sources/house_address_aliases, которые проход дописал победителю, +-- НЕ удаляются: они собраны из собственных полей победителя и остались бы верны и без +-- слияния; +-- * если id проигравшего уже занят — запись пропускается со статусом, откат не гадает. +CREATE OR REPLACE FUNCTION house_merge_undo( + p_batch uuid, + p_only_losers bigint[] DEFAULT NULL +) +RETURNS TABLE ( + out_log_id bigint, + out_loser_id bigint, + out_keeper_id bigint, + out_status text +) +LANGUAGE plpgsql +AS $$ +DECLARE + rec record; + v_table text; + v_column text; + v_ids bigint[]; + v_rows jsonb; + v_field text; + v_repointed int; + v_restored int; + v_lost int; + v_n int; + -- Список полей ДОЛЖЕН совпадать с SET в house_dedup_merge._CARRY_OVER_IDENTITY_SQL; + -- за расхождением следит тест test_undo_carryover_fields_match_merge_carryover. + c_carry_fields constant text[] := ARRAY[ + 'house_fias_id', 'cadastral_number', 'gar_house_guid', 'gar_flat_count', + 'gar_matched_at', 'gar_match_method', 'dadata_qc_geo', 'dadata_qc_house', + 'dadata_enriched_at' + ]; +BEGIN + FOR rec IN + SELECT * + FROM house_merge_log l + WHERE l.batch_id = p_batch + AND (p_only_losers IS NULL OR l.loser_id = ANY (p_only_losers)) + ORDER BY l.id DESC + LOOP + out_log_id := rec.id; + out_loser_id := rec.loser_id; + out_keeper_id := rec.keeper_id; + + IF EXISTS (SELECT 1 FROM houses h WHERE h.id = rec.loser_id) THEN + out_status := 'skipped: houses.id ' || rec.loser_id || ' занят — уже откачено?'; + RETURN NEXT; + CONTINUE; + END IF; + + -- 1. Воскресить проигравшего целиком из снимка (все колонки, включая geom). + INSERT INTO houses + SELECT r.* FROM jsonb_populate_record(NULL::houses, rec.loser_row) r; + + -- 2. Вернуть ссылки уцелевших детей. Условие «сейчас указывает на победителя» + -- защищает от затирания строк, которые после слияния перепривязали чем-то ещё. + v_repointed := 0; + FOR v_table, v_column, v_ids IN + SELECT split_part(e.key, '.', 1), + split_part(e.key, '.', 2), + ARRAY(SELECT jsonb_array_elements_text(e.value)::bigint) + FROM jsonb_each(rec.children_repointed) AS e + LOOP + EXECUTE format( + 'UPDATE %I SET %I = $1 WHERE id = ANY ($2) AND %I = $3', + v_table, v_column, v_column + ) USING rec.loser_id, v_ids, rec.keeper_id; + GET DIAGNOSTICS v_n = ROW_COUNT; + v_repointed := v_repointed + v_n; + END LOOP; + + -- 3. Вернуть детей, удалённых по коллизии UNIQUE. Место могло остаться занятым + -- строкой победителя — тогда DO NOTHING, и это попадёт в отчёт как «не вернулось». + v_restored := 0; + v_lost := 0; + FOR v_table, v_rows IN + SELECT e.key, e.value FROM jsonb_each(rec.children_deleted) AS e + LOOP + EXECUTE format( + 'INSERT INTO %I SELECT r.* FROM jsonb_array_elements($1) AS el, ' + 'LATERAL jsonb_populate_record(NULL::%I, el) r ON CONFLICT DO NOTHING', + v_table, v_table + ) USING v_rows; + GET DIAGNOSTICS v_n = ROW_COUNT; + v_restored := v_restored + v_n; + v_lost := v_lost + (jsonb_array_length(v_rows) - v_n); + END LOOP; + + -- 4. Снять перенос метаданных с победителя. Только там, где до слияния было NULL И + -- текущее значение всё ещё РОВНО то, что принёс этот проигравший: если поле успел + -- заполнить загрузчик (или донором был другой проигравший кластера) — не трогаем. + -- Сравнение в jsonb-пространстве, чтобы один цикл покрыл text/int/timestamptz. + FOREACH v_field IN ARRAY c_carry_fields LOOP + IF rec.keeper_before ->> v_field IS NULL THEN + EXECUTE format( + 'UPDATE houses SET %I = NULL WHERE id = $1 AND to_jsonb(%I) = $2', + v_field, v_field + ) USING rec.keeper_id, rec.loser_row -> v_field; + END IF; + END LOOP; + + out_status := format( + 'restored: дом %s вернулся, ссылок возвращено %s, дочерних строк восстановлено %s' + || CASE WHEN v_lost > 0 THEN ', НЕ ВЕРНУЛОСЬ ' || v_lost || ' (место занято)' + ELSE '' END, + rec.loser_id, v_repointed, v_restored + ); + RETURN NEXT; + END LOOP; +END; +$$; + +COMMENT ON FUNCTION house_merge_undo(uuid, bigint[]) IS + 'Откат слияния домов по журналу house_merge_log (#2690). Аргументы: batch_id (единица ' + 'отката = один вызов merge_duplicate_houses) и опциональный список loser_id для частичного ' + 'отката. Возвращает строку-статус на КАЖДУЮ запись журнала, включая неудачные. ' + 'Транзакции не открывает и не закрывает — вызывающий смотрит отчёт и решает COMMIT/ROLLBACK: ' + ' BEGIN; SELECT * FROM house_merge_undo(''''); -- прочитать статусы -- COMMIT;'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index ef6494e0..63db7822 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -284,9 +284,18 @@ def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None: assert "keeper_geom IS NOT NULL" in canon assert "loser_geom IS NOT NULL" in canon # fias pass drops the distance guard AND the NULL-geom exclusions entirely. - assert "ST_DistanceSphere" not in fias - assert "loser_geom IS NOT NULL" not in fias - assert "keeper_geom IS NOT NULL" not in fias + # + # Asserted on the guard PREDICATE, not on the bare function name: since #2690 the mapping also + # MEASURES the keeper↔loser distance into `distance_m` for the merge journal, on both passes. + # Measuring is the opposite of guarding — the fias pass is precisely where nothing else records + # how far apart the merged rows were — so the name alone can no longer stand in for the guard. + assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias + guard = ( + "AND keeper_geom IS NOT NULL AND loser_geom IS NOT NULL " + "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" + ) + assert guard in canon + assert guard not in fias # the cross-fias anti-over-merge guard is untouched in the canon pass. assert "lower(loser_fias) <> lower(keeper_fias)" in canon @@ -294,12 +303,15 @@ def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None: def test_mapping_sql_geo_guard_param_toggles_only_distance_filter() -> None: """_mapping_sql(apply_geo_guard=...) toggles ONLY the 250 m distance filter; the cross-fias guard is emitted regardless, and the default is True (canon-safe).""" + guard = "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" with_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=True)) without_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=False)) - assert "ST_DistanceSphere" in with_guard - assert "ST_DistanceSphere" not in without_guard + assert guard in with_guard + assert guard not in without_guard # default = True (the canon pass must never lose its guard by omission). - assert "ST_DistanceSphere" in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + assert guard in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + # ...while the journal's distance MEASUREMENT is emitted either way (#2690). + assert "AS distance_m" in with_guard and "AS distance_m" in without_guard # cross-fias guard present in BOTH renderings (independent of the geo guard). assert "lower(loser_fias) <> lower(keeper_fias)" in with_guard assert "lower(loser_fias) <> lower(keeper_fias)" in without_guard @@ -327,7 +339,9 @@ def test_both_passes_share_one_pipeline_no_copy_paste() -> None: assert token in canon and token in fias # the 250 m distance guard is CANON-ONLY (#2187) — fias identity outranks proximity. assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" in canon - assert "ST_DistanceSphere" not in fias + assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias + # ...but the journal's distance MEASUREMENT is on both — measuring is not guarding. + assert "AS distance_m" in canon and "AS distance_m" in fias def test_cross_fias_guard_blocks_slash_collapse_over_merge() -> None: @@ -433,28 +447,62 @@ class _FakeResult: class _Row: - def __init__(self, loser_id: int, keeper_id: int, norm_address: str): + def __init__( + self, + loser_id: int, + keeper_id: int, + norm_address: str, + cluster_key: str = "addr:тест", + distance_m: float | None = 12.0, + ): self.loser_id = loser_id self.keeper_id = keeper_id self.norm_address = norm_address + # journal grounds (#2690): which key value fired, and how far apart the rows were. + self.cluster_key = cluster_key + self.distance_m = distance_m + + +class _ChildRow: + """What a step's RETURNING yields: an id for a survivor, a snapshot for a destroyed row.""" + + def __init__(self, loser_id: int, child_id: int = 1): + self.loser_id = loser_id + self.child_id = child_id + self.row_snapshot = {"id": child_id, "house_id": loser_id} + + +class _FKChild: + def __init__(self, child_table: str, fk_column: str): + self.child_table = child_table + self.fk_column = fk_column class _FakeDB: """Session stand-in: build-mapping + a scripted SELECT result, then per-step rowcounts.""" - def __init__(self, mapping_rows: list[_Row], step_rowcount: int = 1): + def __init__( + self, + mapping_rows: list[_Row], + step_rowcount: int = 1, + fk_children: dict[str, str] | None = None, + ): self._mapping_rows = mapping_rows self._step_rowcount = step_rowcount self._mapping_served = False + # The catalog the FK-child guard reads; defaults to the real live set. + self._fk_children = _FK_CHILDREN if fk_children is None else fk_children self.commits = 0 self.rollbacks = 0 self.executed: list[str] = [] - def execute(self, clause: Any, params: dict | None = None) -> _FakeResult: + def execute(self, clause: Any, params: Any = None) -> _FakeResult: sql = str(getattr(clause, "text", clause)) self.executed.append(sql) if "CREATE TEMP TABLE" in sql: return _FakeResult() + if "FROM pg_constraint" in sql: + return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()]) if "SELECT loser_id, keeper_id, norm_address" in sql: # The service now runs TWO passes (fias, then canon). Model «fias pass found the # duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards. @@ -462,7 +510,13 @@ class _FakeDB: return _FakeResult(rows=[]) self._mapping_served = True return _FakeResult(rows=list(self._mapping_rows)) - # any UPDATE/DELETE/INSERT step (incl. DROP TABLE, carry-over, delete, backfill) + # Steps now RETURN the rows they touched (journal, #2690) — one per scripted rowcount, + # attributed to the first loser so the per-loser bookkeeping has something to bucket. + if "RETURNING" in sql: + loser = self._mapping_rows[0].loser_id if self._mapping_rows else 0 + rows = [_ChildRow(loser, child_id=i + 1) for i in range(self._step_rowcount)] + return _FakeResult(rowcount=self._step_rowcount, rows=rows) + # any other UPDATE/DELETE/INSERT (DROP TABLE, journal, carry-over, delete, backfill) return _FakeResult(rowcount=self._step_rowcount) def commit(self) -> None: @@ -524,7 +578,11 @@ def test_run_wrapper_marks_done_with_counters(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr( hdm, "merge_duplicate_houses", - lambda _db, dry_run=False: {"clusters_merged": 3, "losers_deleted": 5, "dry_run": 0}, + lambda _db, dry_run=False, run_id=None, initiator="manual": { + "clusters_merged": 3, + "losers_deleted": 5, + "dry_run": 0, + }, ) out = hdm.run_house_dedup_merge(object(), run_id=42, params={"dry_run": False}) # type: ignore[arg-type] @@ -541,13 +599,20 @@ def test_run_wrapper_passes_dry_run_param(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setattr(runs_mod, "mark_done", lambda *a, **k: None) monkeypatch.setattr(runs_mod, "mark_failed", lambda *a, **k: None) - def _fake_merge(_db: Any, dry_run: bool = False) -> dict[str, int]: + def _fake_merge( + _db: Any, dry_run: bool = False, run_id: int | None = None, initiator: str = "manual" + ) -> dict[str, int]: captured["dry_run"] = dry_run + captured["run_id"] = run_id + captured["initiator"] = initiator return {"dry_run": int(dry_run)} monkeypatch.setattr(hdm, "merge_duplicate_houses", _fake_merge) hdm.run_house_dedup_merge(object(), run_id=1, params={"dry_run": True}) # type: ignore[arg-type] assert captured["dry_run"] is True + # the journal must be able to say WHICH run did it, and that it was not a human (#2690) + assert captured["run_id"] == 1 + assert captured["initiator"] == "schedule" def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> None: @@ -562,7 +627,9 @@ def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> N lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err), ) - def _boom(_db: Any, dry_run: bool = False) -> dict[str, int]: + def _boom( + _db: Any, dry_run: bool = False, run_id: int | None = None, initiator: str = "manual" + ) -> dict[str, int]: raise RuntimeError("merge exploded") monkeypatch.setattr(hdm, "merge_duplicate_houses", _boom) @@ -642,12 +709,16 @@ def test_real_merge_repoints_dedups_deletes_and_is_idempotent() -> None: db = _live_session() assert db is not None try: - # Two houses at the SAME address. Keeper (geom present) should win. + # Two houses at the SAME address, ~10 m apart (the #2187 canon geo guard needs geom + # on BOTH sides). Keeper = min(id) once geom and listing counts tie. db.execute( _t( - "INSERT INTO houses (id, source, ext_house_id, address, lat, lon) VALUES " - "(900001, 'avito', 'EXT-KEEP', 'тестдом 1772, 1', 56.84, 60.60)," - "(900002, 'cian', 'EXT-LOSE', 'тестдом 1772, 1', NULL, NULL)" + # url is NOT NULL in houses (002_core_tables); nothing here asserts on it, + # so 'u' is a placeholder. These live-DB fixtures self-skip in CI, which is + # how they silently drifted out of sync with the schema in the first place. + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES " + "(900001, 'avito', 'EXT-KEEP','u', 'тестдом 1772, 1', 56.84, 60.60)," + "(900002, 'cian', 'EXT-LOSE','u', 'тестдом 1772, 1', 56.84009, 60.60)" ) ) # listings pointing at BOTH (the loser's must be re-pointed). source_url, dedup_hash, @@ -755,6 +826,9 @@ def test_real_merge_repoints_dedups_deletes_and_is_idempotent() -> None: db.execute( _t("DELETE FROM house_address_aliases WHERE normalized_address = 'тестдом 1772, 1'") ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id IN (900001,900002)")) db.commit() db.close() @@ -781,16 +855,16 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: try: db.execute( _t( - "INSERT INTO houses (id, source, ext_house_id, address, lat, lon) VALUES " + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES " # A — ул/улица spelling variants of the SAME building, ~10 m apart → MERGE - "(900010, 'avito', 'EXT-T-VK', 'улица Тестовая1772, 66', 56.84000, 60.60000)," - "(900011, 'cian', 'EXT-T-VL', 'ул. Тестовая1772, 66', 56.84009, 60.60000)," + "(900010, 'avito', 'EXT-T-VK','u', 'улица Тестовая1772, 66', 56.84000, 60.60000)," + "(900011, 'cian', 'EXT-T-VL','u', 'ул. Тестовая1772, 66', 56.84009, 60.60000)," # B — same canon (ленина-like) but ~5 km apart → geo guard BLOCKS the merge - "(900012, 'avito', 'EXT-T-L1', 'улица Тестовая1772, 5', 56.84000, 60.60000)," - "(900013, 'cian', 'EXT-T-L2', 'улица Тестовая1772, 5', 56.88500, 60.60000)," + "(900012, 'avito', 'EXT-T-L1','u', 'улица Тестовая1772, 5', 56.84000, 60.60000)," + "(900013, 'cian', 'EXT-T-L2','u', 'улица Тестовая1772, 5', 56.88500, 60.60000)," # C — different корпус → different canon, ~10 m apart → NOT merged - "(900014, 'avito', 'EXT-T-M2', 'Тестовая1772, 34к2', 56.84000, 60.60000)," - "(900015, 'cian', 'EXT-T-M4', 'Тестовая1772, 34к4', 56.84009, 60.60000)" + "(900014, 'avito', 'EXT-T-M2','u', 'Тестовая1772, 34к2', 56.84000, 60.60000)," + "(900015, 'cian', 'EXT-T-M4','u', 'Тестовая1772, 34к4', 56.84009, 60.60000)" ) ) db.execute( @@ -840,7 +914,7 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: db.execute( _t( "DELETE FROM house_sources WHERE ext_id IN " - "('EXT-T-VK','EXT-T-VL','EXT-T-L1','EXT-T-L2','EXT-T-M2','EXT-T-M4')" + "('EXT-T-VK','u','EXT-T-VL','u','EXT-T-L1','u','EXT-T-L2','u','EXT-T-M2','u','EXT-T-M4')" ) ) db.execute( @@ -850,6 +924,9 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: "'тестовая1772, 34к2','тестовая1772, 34к4')" ) ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900010 AND 900015")) db.commit() db.close() @@ -877,21 +954,21 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: db.execute( _t( "INSERT INTO houses " - "(id, source, ext_house_id, address, lat, lon, house_fias_id, gar_house_guid, " + "(id, source, ext_house_id, url, address, lat, lon, house_fias_id, gar_house_guid, " " dadata_enriched_at) VALUES " # A — same fias, different canon (different streets), ~10 m apart → FIAS-pass merge - "(900020,'avito','EXT-F-K','ФиасОдин1772, 10', 56.84000,60.60000," + "(900020,'avito','EXT-F-K','u','ФиасОдин1772, 10', 56.84000,60.60000," " 'F-SAME-1772',NULL,NULL)," - "(900021,'cian', 'EXT-F-L','СовсемДругая1772, 77',56.84009,60.60000," + "(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000," " 'F-SAME-1772',NULL,NULL)," # B — same canon (slash-collapse), DIFFERENT fias → cross-fias guard BLOCKS - "(900022,'avito','EXT-B-1','Клара1772, 32',56.84000,60.60000," + "(900022,'avito','EXT-B-1','u','Клара1772, 32',56.84000,60.60000," " 'F-B1-1772',NULL,NULL)," - "(900023,'cian', 'EXT-B-2','Клара1772, 3/2',56.84009,60.60000," + "(900023,'cian', 'EXT-B-2','u','Клара1772, 3/2',56.84009,60.60000," " 'F-B2-1772',NULL,NULL)," # C — same canon, fias only on the loser → canon-pass merge + carry-over - "(900024,'avito','EXT-C-K','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL)," - "(900025,'cian', 'EXT-C-L','Донбасс1772, 8',56.84009,60.60000," + "(900024,'avito','EXT-C-K','u','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL)," + "(900025,'cian', 'EXT-C-L','u','Донбасс1772, 8',56.84009,60.60000," " 'F-CARRY-1772','G-CARRY-1772',NOW())" ) ) @@ -944,7 +1021,7 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: db.execute( _t( "DELETE FROM house_sources WHERE ext_id IN " - "('EXT-F-K','EXT-F-L','EXT-B-1','EXT-B-2','EXT-C-K','EXT-C-L')" + "('EXT-F-K','u','EXT-F-L','u','EXT-B-1','u','EXT-B-2','u','EXT-C-K','u','EXT-C-L')" ) ) db.execute( @@ -954,6 +1031,9 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: "'донбасс1772, 8')" ) ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900020 AND 900025")) db.commit() db.close() @@ -982,16 +1062,16 @@ def test_real_fias_pass_ignores_geo_guard() -> None: db.execute( _t( "INSERT INTO houses " - "(id, source, ext_house_id, address, lat, lon, house_fias_id) VALUES " + "(id, source, ext_house_id, url, address, lat, lon, house_fias_id) VALUES " # A — same fias, loser NULL geom → fias pass merges despite the missing coordinate - "(900030,'avito','EXT-2187-A-K','ФиасГеоA2187, 1', 56.84000,60.60000,'F-A-2187')," - "(900031,'cian', 'EXT-2187-A-L','ФиасГеоAL2187, 2',NULL, NULL, 'F-A-2187')," + "(900030,'avito','EXT-2187-A-K','u','ФиасГеоA2187, 1',56.84,60.6,'F-A-2187')," + "(900031,'cian', 'EXT-2187-A-L','u','ФиасГеоAL2187, 2',NULL,NULL,'F-A-2187')," # B — same fias, ~5 km apart (>250 m) → fias pass merges despite the distance - "(900032,'avito','EXT-2187-B-K','ФиасГеоB2187, 3', 56.84000,60.60000,'F-B-2187')," - "(900033,'cian', 'EXT-2187-B-L','ФиасГеоBL2187, 4',56.88500,60.60000,'F-B-2187')," + "(900032,'avito','EXT-2187-B-K','u','ФиасГеоB2187, 3',56.84,60.6,'F-B-2187')," + "(900033,'cian', 'EXT-2187-B-L','u','ФиасГеоBL2187, 4',56.885,60.6,'F-B-2187')," # C — same canon, NO fias, ~5 km apart → canon pass STILL blocks (guard unchanged) - "(900034,'avito','EXT-2187-C-1','КанонГео2187, 5', 56.84000,60.60000,NULL)," - "(900035,'cian', 'EXT-2187-C-2','КанонГео2187, 5', 56.88500,60.60000,NULL)" + "(900034,'avito','EXT-2187-C-1','u','КанонГео2187, 5', 56.84000,60.60000,NULL)," + "(900035,'cian', 'EXT-2187-C-2','u','КанонГео2187, 5', 56.88500,60.60000,NULL)" ) ) # A loser gets a listing so we prove the re-point still fires with a NULL-geom loser. @@ -1028,6 +1108,199 @@ def test_real_fias_pass_ignores_geo_guard() -> None: db.execute(_t("DELETE FROM listings WHERE id = 910031")) db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900030 AND 900035")) db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900030 AND 900035")) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900030 AND 900035")) db.commit() db.close() + + +# ── Merge journal: reversibility (#2690) ────────────────────────────────────── + + +def test_undo_carryover_fields_match_merge_carryover() -> None: + """Static drift guard: migration 230's undo must un-set EXACTLY the fields the merge carries. + + The undo NULLs the keeper's identity fields that the merge COALESCE-filled from a loser. + If _CARRY_OVER_IDENTITY_SQL ever gains a field and the migration's array does not, the undo + silently leaves that field on the keeper — the restored loser and the keeper would then both + claim the same ФИАС, and the next fias pass would merge them straight back. + """ + migration = (_SQL_DIR / "230_house_merge_log.sql").read_text(encoding="utf-8") + carried = set(re.findall(r"^\s+(\w+)\s*=\s*COALESCE\(k\.", _CARRY_SQL, re.M)) + # slice the ARRAY[...] literal itself — the declaration's own `text[]` also holds a «]» + block = migration[migration.index("c_carry_fields") :] + undone = set(re.findall(r"'(\w+)'", block[block.index("ARRAY[") : block.index("];")])) + assert carried, "could not parse carried fields out of _CARRY_OVER_IDENTITY_SQL" + assert carried == undone, f"carry-over/undo field drift: merge={carried} undo={undone}" + + +def test_journal_written_in_the_same_transaction_as_the_merge() -> None: + """The journal INSERT must sit between mapping and delete, with no commit in between. + + Requirement from #2690: a merge that commits without its journal row is exactly the failure + the journal exists to prevent, so the two must share one transaction. + """ + src = inspect.getsource(hdm._run_merge_pass) + assert "_JOURNAL_INSERT_SQL" in src + assert "db.commit()" not in src, "the pass must not commit — the caller owns the txn" + # phase 1 (snapshots) strictly before the steps mutate anything, delete strictly after. + assert src.index("_JOURNAL_INSERT_SQL") < src.index("for label, sql in _STEPS") + assert src.index("for label, sql in _STEPS") < src.index("_DELETE_LOSERS_SQL") + + +def test_every_step_returns_what_it_touched() -> None: + """Each step must RETURN its rows: ids for survivors, full snapshots for destroyed rows.""" + for label, sql in hdm._STEPS: + assert "RETURNING" in sql, f"{label}: no RETURNING — its rows would go unjournaled" + if label.endswith("(collision-delete)") or label.endswith("(dedup)"): + assert "to_jsonb(" in sql, f"{label}: destroys rows, must snapshot them, not ids" + else: + assert "AS child_id" in sql, f"{label}: re-points rows, must return their ids" + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_merge_is_reversible_via_journal() -> None: + """End-to-end on a real DB: merge → journal is sufficient → undo restores the ORIGINAL state. + + The comparison is over `to_jsonb(row.*)` for every row that existed before the merge — all + columns, not a chosen pair — for houses and for every FK child touched. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + ids = "(900201, 900202)" + try: + # Keeper 900201 and loser 900202: same canon address, ~12 m apart (inside the 250 m + # guard), keeper has the listings so the keeper rule picks it. + db.execute( + _t( + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, geom, " + "year_built, house_fias_id, gar_flat_count, raw_payload) VALUES " + "(900201,'avito','K-2690','http://t/2690/k','улица Журнальная, 7', " + " 56.8400, 60.6000, ST_SetSRID(ST_MakePoint(60.6000,56.8400),4326), " + " 1979, NULL, NULL, '{\"k\":[1,2]}'), " + "(900202,'cian','L-2690','http://t/2690/l','ул. Журнальная,7', " + " 56.8401, 60.6000, ST_SetSRID(ST_MakePoint(60.6000,56.8401),4326), " + # NB: no «:word» inside the literal — SQLAlchemy text() would read it as a bind. + " NULL, 'fias-2690-uuid', 144, '{\"l\":{\"deep\":[3,4]}}')" + ) + ) + db.execute( + _t( + "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, " + "house_id_fk) VALUES " + "(910201,'avito','http://t/2690/1','L1','dh-2690-1',5000000,900201)," + "(910202,'avito','http://t/2690/2','L2','dh-2690-2',5100000,900201)," + "(910203,'cian','http://t/2690/3','L3','dh-2690-3',6000000,900202)" + ) + ) + db.execute( + _t( + "INSERT INTO house_sources (house_id, ext_source, ext_id, confidence, " + "matched_method) VALUES (900201,'avito','S-2690-K',1.0,'t')," + "(900202,'cian','S-2690-L',1.0,'t')" + ) + ) + # Colliding child: identical 6-col UNIQUE key on both → the loser's row is DESTROYED by + # the dedup step. Only a full snapshot can bring it back. + db.execute( + _t( + "INSERT INTO houses_price_dynamics (house_id, month_date, source, room_count, " + "prices_type, period, price_per_sqm) VALUES " + "(900201, DATE '2026-02-01','cian','all','priceSqm','allTime',100000)," + "(900202, DATE '2026-02-01','cian','all','priceSqm','allTime',999999)" + ) + ) + db.commit() + + def snapshot() -> dict[tuple[str, int], Any]: + """to_jsonb of every seeded row, keyed by (table, id) — the full-fidelity state.""" + out: dict[tuple[str, int], Any] = {} + for tbl, col in ( + ("houses", "id"), + ("listings", "house_id_fk"), + ("house_sources", "house_id"), + ("houses_price_dynamics", "house_id"), + ): + where = f"id IN {ids}" if tbl == "houses" else f"{col} IN {ids}" + for r in db.execute( + _t(f"SELECT id, to_jsonb(t.*) AS j FROM {tbl} t WHERE {where}") + ): + out[(tbl, r.id)] = r.j + return out + + before = snapshot() + assert len(before) == 9, f"fixture should seed 9 rows, got {sorted(before)}" + + # ── merge ── + out = hdm.merge_duplicate_houses(db, dry_run=False, initiator="test") + assert out["losers_deleted"] == 1 + assert db.execute(_t(f"SELECT count(*) FROM houses WHERE id IN {ids}")).scalar() == 1 + + # ── the journal alone must be able to NAME what went into what ── + row = db.execute( + _t("SELECT * FROM house_merge_log WHERE loser_id = 900202 ORDER BY id DESC LIMIT 1") + ).one() + assert (row.loser_id, row.keeper_id) == (900202, 900201) + assert row.merge_pass == "canon" and row.geo_guard is True + assert row.cluster_key.startswith("addr:") + assert 0 < row.distance_m < 250, "distance to the keeper must be recorded, in metres" + assert row.initiator == "test" + # full snapshot of the deleted row, not a reference to it + assert row.loser_row == before[("houses", 900202)] + # keeper as it stood BEFORE the identity carry-over (fias still empty there, filled now) + assert row.keeper_before["house_fias_id"] is None + assert ( + db.execute(_t("SELECT house_fias_id FROM houses WHERE id = 900201")).scalar() + == "fias-2690-uuid" + ), "carry-over should have moved the loser's fias up" + # children: the loser's listing moved by id, the destroyed price row by content + assert row.children_repointed["listings.house_id_fk"] == [910203] + assert [r["price_per_sqm"] for r in row.children_deleted["houses_price_dynamics"]] == [ + 999999 + ] + + # ── undo ── + report = db.execute( + _t("SELECT * FROM house_merge_undo(CAST(:b AS uuid))"), {"b": str(row.batch_id)} + ).all() + assert len(report) == 1 and report[0].out_status.startswith("restored:"), report + db.commit() + + after = snapshot() + # every row that existed before is back, byte-identical, on every column + assert {k: v for k, v in after.items() if k in before} == before + # the ONLY residue is the house_sources row the merge backfilled for the keeper. + # migration 230 documents this: it is built from the keeper's OWN ext_house_id, so + # it would have been true without the merge too. Asserted, not assumed. + residue = [v for k, v in after.items() if k not in before] + assert all(v["matched_method"] == "backfill_dedup_merge" for v in residue), residue + finally: + db.rollback() + db.execute(_t(f"DELETE FROM listings WHERE house_id_fk IN {ids}")) + db.execute(_t("DELETE FROM listings WHERE id IN (910201,910202,910203)")) + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id = 900202")) + db.execute(_t("DELETE FROM house_address_aliases WHERE house_id IN (900201,900202)")) + db.execute(_t(f"DELETE FROM houses WHERE id IN {ids}")) + db.commit() + db.close() + + +def test_merge_refuses_when_an_fk_child_is_unhandled() -> None: + """A new FK child on houses(id) must ABORT the merge, not be CASCADE-deleted unjournaled. + + 9 of the 11 FKs are ON DELETE CASCADE. A child the _STEPS pipeline does not know about is + therefore destroyed by `DELETE FROM houses` — no re-point step touches it, no RETURNING + records it, and the journal would claim a complete snapshot it does not have. Migration 133 + already broke on prod over a missed child; there it failed loudly, here it would be silent. + """ + db = _FakeDB( + mapping_rows=[_Row(2, 1, "ул. ленина, 5")], + fk_children={**_FK_CHILDREN, "house_brand_new_child": "house_id"}, + ) + with pytest.raises(RuntimeError, match="house_brand_new_child"): + hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert db.commits == 0, "an unhandled child must abort before anything is committed" From 3ee99efaa44d2a094e326502bd22b2a5863140fb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 19:10:27 +0300 Subject: [PATCH 062/130] =?UTF-8?q?chore(tradein/privacy):=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D1=83=D0=BC=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20231=20=D0=B8=20merge=20main=20-=20=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B5=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B0=20(#2547)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/lead.py | 2 +- tradein-mvp/backend/app/core/config.py | 2 +- .../app/tasks/purge_expired_trade_in_data.py | 4 ++-- .../229_trade_in_estimates_consent_proof.sql | 2 +- ...sql => 231_trade_in_privacy_retention.sql} | 2 +- .../tests/test_purge_expired_trade_in_data.py | 24 +++++++++---------- 6 files changed, 18 insertions(+), 18 deletions(-) rename tradein-mvp/backend/data/sql/{230_trade_in_privacy_retention.sql => 231_trade_in_privacy_retention.sql} (99%) diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 54795e32..6ac5ba07 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -118,7 +118,7 @@ async def create_trade_in_lead( # ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет # X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден. # - # ЭТАП 4 B2C: expires_at (migration 230) — раньше лид хранился бессрочно + # ЭТАП 4 B2C: expires_at (migration 231) — раньше лид хранился бессрочно # (никакого TTL вообще не было, в отличие от trade_in_estimates.expires_at). # Считаем на insert-time тем же паттерном, что estimator.py делает для # trade_in_estimates — retention-период вынесен в settings, не хардкод. diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 0e716969..84945e41 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -824,7 +824,7 @@ class Settings(BaseSettings): # к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS. trade_in_estimate_retention_hours: int = 24 - # trade_in_leads.expires_at TTL (дни от момента создания, migration 230). + # trade_in_leads.expires_at TTL (дни от момента создания, migration 231). # У trade_in_leads раньше вообще не было срока хранения — лид (телефон + # согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для # НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index dcdaad71..32dba363 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -1,7 +1,7 @@ """Physically delete expired personal data — ЭТАП 4 B2C retention enforcement (152-ФЗ). WHY: - trade_in_estimates.expires_at (и, начиная с migration 230, trade_in_leads.expires_at) + trade_in_estimates.expires_at (и, начиная с migration 231, trade_in_leads.expires_at) defined a retention window, but neither table had any background job that actually DELETEd rows once expired -- expires_at was used ONLY as a read-time filter (GET /estimate/{id}: "AND expires_at > NOW()"). Personal data (address / phone) @@ -11,7 +11,7 @@ WHY: WHAT: Batched physical DELETE for both tables, run nightly by the kit-scheduler (see app.services.product_handlers._job_purge_expired_trade_in_data, scrape_schedules - row seeded by migration 230 -- seeded enabled=false, see that migration's docstring + row seeded by migration 231 -- seeded enabled=false, see that migration's docstring for why). Same architecture as app/tasks/deactivate_stale_avito.py (sync, DB-only, invoked via run_in_executor from the async kit handler). diff --git a/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql b/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql index ee3bfbf8..482f9405 100644 --- a/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql +++ b/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql @@ -41,7 +41,7 @@ -- defense-in-depth на случай будущего кода, который забудет про gate). -- -- Индекс на expires_at — обслуживает будущую retention-задачу --- purge_expired_trade_in_data (см. migration 230), которая физически +-- purge_expired_trade_in_data (см. migration 231), которая физически -- удаляет строки, чей expires_at истёк (сегодня expires_at используется -- ТОЛЬКО как read-time фильтр, см. GET /estimate/{id}: "AND expires_at > -- NOW()" — без индекса такой batched-DELETE делал бы full scan таблицы diff --git a/tradein-mvp/backend/data/sql/230_trade_in_privacy_retention.sql b/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql similarity index 99% rename from tradein-mvp/backend/data/sql/230_trade_in_privacy_retention.sql rename to tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql index d0d5817b..c78c7780 100644 --- a/tradein-mvp/backend/data/sql/230_trade_in_privacy_retention.sql +++ b/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql @@ -1,4 +1,4 @@ --- 230_trade_in_privacy_retention.sql +-- 231_trade_in_privacy_retention.sql -- ЭТАП 4 B2C launch — retention enforcement (152-ФЗ): срок хранения ДОЛЖЕН -- приводить к физическому удалению, а не быть декоративным полем. -- diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index 20a695e2..a953c278 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -26,7 +26,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from app.tasks import purge_expired_trade_in_data as task_mod _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" -_MIGRATION_230 = _SQL_DIR / "230_trade_in_privacy_retention.sql" +_MIGRATION_231 = _SQL_DIR / "231_trade_in_privacy_retention.sql" class _FakeResult: @@ -197,29 +197,29 @@ def test_idempotent_zero_rowcount_is_not_an_error(monkeypatch: pytest.MonkeyPatc assert marked["kind"] == "done" -# ── migration 230 ──────────────────────────────────────────────────────────── +# ── migration 231 ──────────────────────────────────────────────────────────── -def test_migration_230_exists() -> None: - assert _MIGRATION_230.is_file(), f"missing migration: {_MIGRATION_230}" +def test_migration_231_exists() -> None: + assert _MIGRATION_231.is_file(), f"missing migration: {_MIGRATION_231}" -def test_migration_230_is_transactional() -> None: - sql = _MIGRATION_230.read_text("utf-8") +def test_migration_231_is_transactional() -> None: + sql = _MIGRATION_231.read_text("utf-8") assert "BEGIN;" in sql assert "COMMIT;" in sql -def test_migration_230_backfills_and_sets_not_null() -> None: - sql = _MIGRATION_230.read_text("utf-8") +def test_migration_231_backfills_and_sets_not_null() -> None: + sql = _MIGRATION_231.read_text("utf-8") assert "ADD COLUMN IF NOT EXISTS expires_at" in sql assert "WHERE expires_at IS NULL" in sql assert "SET NOT NULL" in sql assert "180 days" in sql -def test_migration_230_seeds_purge_schedule_disabled_by_default() -> None: - sql = _MIGRATION_230.read_text("utf-8") +def test_migration_231_seeds_purge_schedule_disabled_by_default() -> None: + sql = _MIGRATION_231.read_text("utf-8") assert "'purge_expired_trade_in_data'" in sql assert "ON CONFLICT (source) DO NOTHING" in sql # Seeded disabled -- first automated PII-DELETE job in trade-in deserves a @@ -227,6 +227,6 @@ def test_migration_230_seeds_purge_schedule_disabled_by_default() -> None: assert re.search(r"'purge_expired_trade_in_data',\s*\n\s*false,", sql) -def test_migration_230_no_psycopg_trap() -> None: - sql = _MIGRATION_230.read_text("utf-8") +def test_migration_231_no_psycopg_trap() -> None: + sql = _MIGRATION_231.read_text("utf-8") assert not re.search(r":\w+::", sql) From 91423e0b5361a2995407da34f0ee1e90961c66e3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 16:19:56 +0000 Subject: [PATCH 063/130] =?UTF-8?q?fix(tradein/scrapers):=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=E2=80=94=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B0=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B8=20(#2731)=20(#?= =?UTF-8?q?2742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../232_listings_observation_time_meaning.sql | 84 +++++ .../tests/test_2731_observation_timestamps.py | 292 ++++++++++++++++++ .../tests/test_scraped_at_bump_2206.py | 20 +- .../scraper-kit/src/scraper_kit/base.py | 23 +- .../src/scraper_kit/snapshot_writer.py | 17 +- 5 files changed, 423 insertions(+), 13 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql create mode 100644 tradein-mvp/backend/tests/test_2731_observation_timestamps.py diff --git a/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql b/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql new file mode 100644 index 00000000..adf9e522 --- /dev/null +++ b/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql @@ -0,0 +1,84 @@ +-- 232_listings_observation_time_meaning.sql +-- Purpose (#2731): зафиксировать в схеме, что содержательные метки наблюдения до +-- этой правки несли время НАЧАЛА транзакции сбора, а не момент наблюдения строки, +-- и назвать дату, с которой их смысл изменился. +-- +-- Dependencies: 002_core_tables.sql (listings.scraped_at/last_seen_at), +-- 016_listings_snapshots.sql (listings_snapshots.observed_at), +-- 161_backfill_scraped_at_active_recent.sql (ретро-выравнивание scraped_at по last_seen_at). +-- Apply after: 230_house_merge_log.sql +-- Идемпотентно: только COMMENT ON COLUMN (перезаписывает сам себя), данных не трогает. +-- +-- ── ЧТО БЫЛО ───────────────────────────────────────────────────────────────── +-- Писатель объявлений (packages/scraper-kit/.../base.py::save_listings + snapshot_writer) +-- ставил NOW(). В PostgreSQL NOW() == transaction_timestamp() — время старта транзакции. +-- save_listings коммитит ОДИН раз в конце всего batch'а, поэтому одну метку получали все +-- строки одного вызова, сколько бы он ни работал. +-- +-- Прод-замер 2026-08-06 (listings_snapshots, строк / различных меток на прогон): +-- run 3303 — 219 / 1 (прогон шёл 7 минут, все метки на нулевой секунде); +-- run 3299 — 235 / 1; run 3293 — 297 / 1; +-- run 3229 — 2643 / 55 (метка на вызов save_listings, а не на строку). +-- listings за те же сутки: 219/1, 235/1, 297/1 … — и scraped_at, и last_seen_at, причём +-- у 100% строк они РАВНЫ между собой (замер по часам: eq == rows во всех корзинах). +-- +-- ── ЧЕГО ЭТО НЕ ЛОМАЛО ─────────────────────────────────────────────────────── +-- Фильтры свежести и TTL — НЕ искажены. Смещение равно длительности прогона: типично +-- минуты, худший случай на проде 17.3 ч (cian_full_load). Против окна свежести в 14 суток +-- это 0.03-5%. Утверждение «долгие прогоны ломают фильтр свежести» проверено и снято. +-- +-- ── ЧТО ЭТО ЛОМАЛО ─────────────────────────────────────────────────────────── +-- Разрешение во времени. По данным нельзя восстановить ни темп сбора, ни порядок строк +-- внутри прогона: все они выглядят одномоментными. Отсюда же следовала невозможность +-- бэкфилла run_id (#2701). Ошибка тихая — значения правдоподобны. +-- +-- ── ПОЧЕМУ ИСТОРИЮ НЕ ЧИНИМ ────────────────────────────────────────────────── +-- Внутрипрогонное время НИГДЕ БОЛЬШЕ НЕ СОХРАНЯЛОСЬ: у прогона есть только started_at и +-- finished_at, а распределение строк между ними неизвестно. Строки ДО перехода +-- невосстановимы — их метки помечаются, а не переписываются. +-- +-- ── ПОЧЕМУ statement_timestamp(), А НЕ clock_timestamp() ───────────────────── +-- В #2702/#2718 (служебные колонки прогона) взяли clock_timestamp() — там колонка одна. +-- Здесь в одном statement'е пишутся ДВЕ колонки, и они обязаны совпадать: после #2206 +-- scraped_at и last_seen_at равны, и на этом равенстве стоит предикат миграции 161 +-- (`WHERE last_seen_at > scraped_at` как признак «видели живым, но не пере-скрейпили»). +-- Проверка на проде: `clock_timestamp() = clock_timestamp()` → false, +-- `statement_timestamp() = statement_timestamp()` → true. При этом statement_timestamp() +-- двигается ОТ STATEMENT'А К STATEMENT'У внутри одной транзакции (проверено: 1.2 с между +-- соседними запросами при неподвижном now()), а каждый upsert объявления — свой statement. +-- Итог: построчная метка без ложного расхождения колонок. +-- +-- Set-based писатели (listing_source_snapshots, deactivate_stale_avito) НАМЕРЕННО оставлены +-- на now(): там один statement пишет десятки тысяч строк, и одна метка — это правда о нём. +-- clock_timestamp() выдал бы там ложное разрешение: на проде +-- `count(DISTINCT clock_timestamp())` по 200 000 строк одного statement'а = 27 783 разных +-- значения, кодирующих порядок обработки строк планировщиком, а не порядок наблюдения. + +BEGIN; + +COMMENT ON COLUMN listings_snapshots.observed_at IS + 'Момент наблюдения снимка. С #2731 (2026-08-06) — statement_timestamp(), то есть ' + 'время записи КОНКРЕТНОЙ строки. У строк ДО этой даты значение общее на весь вызов ' + 'save_listings (писалось now() = старт транзакции batch''а): на проде 219 снимков ' + 'семиминутного прогона несли одну метку. Для строк до перехода observed_at читать как ' + '«прогон, в котором строку увидели», а НЕ как момент; темп сбора и порядок строк ' + 'внутри прогона по историческим данным невосстановимы — внутрипрогонное время нигде ' + 'не сохранялось.'; + +COMMENT ON COLUMN listings.scraped_at IS + 'Момент последнего скрейпа объявления. С #2206 двигается и при ре-подтверждении живым ' + '(не только при вставке), с #2731 (2026-08-06) пишется statement_timestamp() — ' + 'построчно. У строк ДО этой даты — время старта транзакции batch''а, общее на все ' + 'строки вызова save_listings. На фильтры свежести это не влияло: смещение равно ' + 'длительности прогона (минуты; худший случай 17.3 ч у cian_full_load) против окна в ' + '14 суток. Невосстановимо потеряно другое — разрешение внутри прогона.'; + +COMMENT ON COLUMN listings.last_seen_at IS + 'Момент последнего подтверждения, что объявление живо. Пишется тем же statement''ом, ' + 'что и scraped_at, и потому РАВЕН ему — это свойство сохранено намеренно: ' + 'с #2731 (2026-08-06) обе колонки берут statement_timestamp(), стабильный в statement''е ' + '(clock_timestamp() развёл бы их на микросекунды и сделал бы предикат ' + '`last_seen_at > scraped_at` из миграции 161 истинным почти для всех строк). ' + 'У строк ДО перехода — время старта транзакции batch''а, см. комментарий к scraped_at.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_2731_observation_timestamps.py b/tradein-mvp/backend/tests/test_2731_observation_timestamps.py new file mode 100644 index 00000000..d78fad6d --- /dev/null +++ b/tradein-mvp/backend/tests/test_2731_observation_timestamps.py @@ -0,0 +1,292 @@ +"""#2731: метка наблюдения — время строки, а не старта транзакции сбора. + +Что было. `save_listings` и `upsert_listing_snapshot` писали содержательные колонки +времени через `NOW()`, а `NOW()` в PostgreSQL — синоним `transaction_timestamp()`: +он замерзает на СТАРТЕ транзакции. Коммит у писателя объявлений ОДИН, в конце всего +batch'а, поэтому все строки вызова получали одну и ту же метку — сколько бы он ни работал. + +Прод-замер 2026-08-06 (listings_snapshots, строк / различных меток на прогон): + run 3303 — 219 / 1, при семи минутах работы (11:59:59 → 12:07:19); + run 3299 — 235 / 1; run 3293 — 297 / 1; + run 3229 — 2643 / 55 — метка на ВЫЗОВ save_listings, а не на строку. +`listings.scraped_at` и `last_seen_at` за те же сутки схлопнуты так же и при этом равны +друг другу у 100% строк (по часам: eq == rows во всех корзинах). + +Цена дефекта — НЕ фильтры свежести (смещение равно длительности прогона: минуты, худший +случай 17.3 ч, против окна 14 суток), а разрешение во времени: темп сбора и порядок строк +внутри прогона по данным не восстановить (отсюда же невозможность бэкфилла run_id, #2701). + +Почему `statement_timestamp()`, а не `clock_timestamp()` как в #2702/#2718: здесь в одном +statement'е пишутся ДВЕ колонки, обязанные совпадать (после #2206 scraped_at = last_seen_at, +на этом равенстве стоит предикат миграции 161). На проде проверено: +`clock_timestamp() = clock_timestamp()` → false, `statement_timestamp() = ...` → true. +Модель БД ниже воспроизводит ровно это различие, поэтому «починка» через clock_timestamp() +провалит тест на согласованность. + +Фальсификация: на старом коде (`NOW()`) тесты 1 и 2 дают одну метку на все строки. +""" + +from __future__ import annotations + +import inspect +import os +import re +from contextlib import contextmanager +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit import base as kit_base +from scraper_kit import snapshot_writer as kit_snapshot +from scraper_kit.base import ScrapedLot, save_listings + +# Сколько «работает» писатель на одну строку. Пять лотов → 2 с работы одной транзакции: +# требование задачи — писатель, отработавший ДОЛЬШЕ СЕКУНДЫ, оставляет различные метки. +_STEP_SEC = 0.4 + +# Содержательные колонки времени, за которыми следим, по таблицам-писателям. +_WATCHED: dict[str, tuple[str, ...]] = { + "listings": ("scraped_at", "last_seen_at"), + "listings_snapshots": ("observed_at",), +} + + +def _strip_sql_comments(sql: str) -> str: + """Убрать `--`-комментарии: в них есть и скобки, и слово NOW() в объяснительной прозе.""" + return re.sub(r"--[^\n]*", "", sql) + + +def _split_top(items: str) -> list[str]: + """Разбить список SQL-элементов по запятым ВЕРХНЕГО уровня (CAST(:x AS t) — один).""" + out: list[str] = [] + depth = 0 + cur = "" + for ch in items: + if ch == "," and depth == 0: + out.append(cur.strip()) + cur = "" + continue + depth += (ch == "(") - (ch == ")") + cur += ch + out.append(cur.strip()) + return out + + +def _balanced(sql: str, open_idx: int) -> tuple[str, int]: + """Содержимое скобки, открытой на open_idx, и индекс её закрывающей пары.""" + depth = 0 + for i in range(open_idx, len(sql)): + depth += (sql[i] == "(") - (sql[i] == ")") + if depth == 0: + return sql[open_idx + 1 : i], i + raise AssertionError("несбалансированные скобки в SQL") + + +class _Row: + """Строка ответа: id/inserted для INSERT INTO listings ... RETURNING.""" + + id = 42 + inserted = False + card_hash = None + + +class _FakeResult: + def __init__(self, row: _Row | None) -> None: + self._row = row + + def fetchone(self) -> _Row | None: + return self._row + + def first(self) -> _Row | None: + return self._row + + def scalar_one_or_none(self) -> None: + return None + + +class _FakePg: + """Мини-модель PostgreSQL на три функции времени и ленивую транзакцию. + + `now()` == `transaction_timestamp()` — замерзает на старте транзакции (autobegin на + первом execute, сброс на commit/rollback); + `statement_timestamp()` — момент начала ТЕКУЩЕГО statement'а: двигается от запроса к + запросу и СТАБИЛЕН внутри запроса; + `clock_timestamp()` — настоящие часы, вычисляются на КАЖДЫЙ вызов отдельно, поэтому + два вызова в одном statement'е дают разные значения (проверено на проде). + + Каждый execute «работает» _STEP_SEC — так модель отличает построчную метку от общей. + """ + + def __init__(self) -> None: + self.wall: float = 0.0 + self.tx_start: float | None = None + self.stmt_start: float = 0.0 + self._clock_calls: int = 0 + # Что осело в таблицах: список строк {колонка: значение}. + self.rows: dict[str, list[dict[str, float]]] = {t: [] for t in _WATCHED} + + # ── модель времени ──────────────────────────────────────────────────────── + def _value(self, func: str) -> float: + if func == "now": + assert self.tx_start is not None + return self.tx_start + if func == "statement_timestamp": + return self.stmt_start + if func == "clock_timestamp": + self._clock_calls += 1 # каждый вызов — своё значение + return self.stmt_start + self._clock_calls * 1e-6 + raise AssertionError(f"неизвестная функция времени: {func}") + + # ── разбор писателя ─────────────────────────────────────────────────────── + def _record(self, sql: str) -> None: + ins = re.search(r"INSERT INTO (listings|listings_snapshots)\s*\(", sql) + if ins is not None: + table = ins.group(1) + cols, end = _balanced(sql, ins.end() - 1) + vals_kw = re.compile(r"\bVALUES\s*\(").search(sql, end) + assert vals_kw is not None, "INSERT без VALUES" + vals, _ = _balanced(sql, vals_kw.end() - 1) + row: dict[str, float] = {} + for col, val in zip(_split_top(cols), _split_top(vals), strict=False): + f = re.fullmatch(r"(\w+)\s*\(\s*\)", val) + if col in _WATCHED[table] and f is not None: + row[col] = self._value(f.group(1).lower()) + if row: + self.rows[table].append(row) + return + upd = re.search(r"UPDATE (listings)\b", sql) + if upd is not None: # reconcile-путь при дрейфе dedup_hash + table = upd.group(1) + row = {} + for col in _WATCHED[table]: + m = re.search(rf"\b{col}\s*=\s*(\w+)\s*\(\s*\)", sql) + if m is not None: + row[col] = self._value(m.group(1).lower()) + if row: + self.rows[table].append(row) + + # ── интерфейс сессии ────────────────────────────────────────────────────── + def execute(self, stmt: Any, params: Any = None) -> _FakeResult: + if self.tx_start is None: + self.tx_start = self.wall + self.stmt_start = self.wall + self.wall += _STEP_SEC # statement отработал + sql = _strip_sql_comments(str(stmt)) + self._record(sql) + if "INSERT INTO listings (" in sql: + return _FakeResult(_Row()) + return _FakeResult(None) + + def commit(self) -> None: + self.tx_start = None + + def rollback(self) -> None: + self.tx_start = None + + def begin_nested(self) -> Any: + @contextmanager + def _ctx() -> Any: + yield MagicMock() + + return _ctx() + + +def _matcher() -> MagicMock: + matcher = MagicMock() + matcher.match_or_create_house.return_value = (101, 1.0, "new") + matcher.upsert_listing_source.return_value = None + return matcher + + +def _lots(n: int = 5) -> list[ScrapedLot]: + return [ + ScrapedLot( + source="cian", + source_url=f"https://ekb.cian.ru/sale/flat/{i}/", + source_id=str(i), + price_rub=5_000_000 + i, + ) + for i in range(n) + ] + + +def _run_writer() -> _FakePg: + db = _FakePg() + save_listings(db, _lots(), matcher=_matcher(), region_code=66) + return db + + +# ── 1. Снимки одного batch'а датируются построчно ──────────────────────────── + + +def test_snapshot_marks_differ_per_row() -> None: + """Прогон 3303 дословно: строки одного вызова — разные моменты, а не один. + + На старом коде (`NOW()`) все снимки batch'а несут метку старта транзакции — ровно + 219 строк на одну метку, ради чего заведена задача. + """ + db = _run_writer() + marks = [row["observed_at"] for row in db.rows["listings_snapshots"]] + + assert len(marks) == 5, "снимок пишется на каждый сохранённый лот" + assert max(marks) - min(marks) > 1.0, "писатель отработал дольше секунды" + assert len(set(marks)) == len(marks), "у строк одного batch'а обязаны быть разные метки" + + +# ── 2. scraped_at/last_seen_at: построчно, но по-прежнему равны между собой ── + + +def test_listing_marks_differ_per_row_and_stay_equal_to_each_other() -> None: + """Две колонки одного statement'а: построчная метка без нового расхождения. + + Равенство scraped_at = last_seen_at — не косметика: на нём стоит предикат миграции + 161 (`last_seen_at > scraped_at` как признак «видели живым, но не пере-скрейпили»). + `clock_timestamp()` вычисляется на каждый вызов отдельно и развёл бы колонки на + микросекунды — модель БД это воспроизводит, поэтому такая «починка» провалит тест. + """ + db = _run_writer() + rows = db.rows["listings"] + + assert len(rows) == 5 + for row in rows: + assert row["scraped_at"] == row["last_seen_at"], "колонки statement'а обязаны совпадать" + scraped = [row["scraped_at"] for row in rows] + assert max(scraped) - min(scraped) > 1.0 + assert len(set(scraped)) == len(scraped), "метки строк одного batch'а обязаны различаться" + + +# ── 3. Инвариант источника: содержательная колонка не пишется now() ───────── + + +@pytest.mark.parametrize("mod", [kit_base, kit_snapshot], ids=["base", "snapshot_writer"]) +def test_no_content_timestamp_is_written_with_now(mod: Any) -> None: + """`<колонка> = NOW()` в этих писателях не имеет корректного применения. + + Комментарии вырезаются: `NOW()` там остаётся в объяснительной прозе (и должен — + без неё следующий читатель повторит дефект). + """ + src = _strip_sql_comments(inspect.getsource(mod)) + cols = "|".join(sorted({c for t in _WATCHED.values() for c in t})) + assert re.search(rf"\b({cols})\s*=\s*NOW\s*\(\s*\)", src, re.I) is None + assert "statement_timestamp()" in src + + +# ── 4. Историческая граница зафиксирована в схеме ──────────────────────────── + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION = _SQL_DIR / "232_listings_observation_time_meaning.sql" + + +def test_migration_232_marks_the_transition_date_and_unrecoverable_history() -> None: + """Аналитике нужно знать, где сменился смысл колонки и что до него не чинится.""" + sql = _MIGRATION.read_text("utf-8") + assert "BEGIN;" in sql and "COMMIT;" in sql + for col in ("listings_snapshots.observed_at", "listings.scraped_at", "listings.last_seen_at"): + assert f"COMMENT ON COLUMN {col} IS" in sql + assert sql.count("#2731 (2026-08-06)") >= 3, "дата перехода — в каждом комментарии" + assert "невосстановим" in sql + assert not re.search(r":\w+::", sql), "psycopg v3: никаких :param::type" diff --git a/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py b/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py index f5e3b247..784df63d 100644 --- a/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py +++ b/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py @@ -7,7 +7,8 @@ эстиматору было видно лишь ~45.6% активного инвентаря (CIAN — 5.9%). Фикс: и ON CONFLICT DO UPDATE, и dedup-drift reconcile UPDATE теперь выставляют -scraped_at = NOW() рядом с last_seen_at = NOW(). Проверяем оба пути в +scraped_at рядом с last_seen_at (с #2731 — statement_timestamp(), см. +test_2731_observation_timestamps.py; до него NOW()). Проверяем оба пути в `scraper_kit.base` (единственный живой модуль — `app.services.scrapers.base` удалён #2397 финальный шаг E, mirror-тесты через legacy убраны) и свойства ретро-бэкфилл-миграции 161. @@ -120,7 +121,12 @@ def _kit_matcher() -> MagicMock: def test_kit_on_conflict_bumps_scraped_at() -> None: - """kit ON CONFLICT DO UPDATE двигает scraped_at = NOW().""" + """kit ON CONFLICT DO UPDATE двигает scraped_at рядом с last_seen_at. + + #2731: обе метки пишутся statement_timestamp() (построчно, но одинаково внутри + statement'а) вместо NOW() — инвариант #2206 «scraped_at двигается вместе с + last_seen_at» от этого не меняется, меняется только источник времени. + """ db = _mock_db_update_path(inserted=False) lot = KitLot( source="cian", @@ -134,12 +140,12 @@ def test_kit_on_conflict_bumps_scraped_at() -> None: assert (inserted, updated) == (0, 1) sql = _find_sql(db, "INSERT INTO listings (") - assert "scraped_at = NOW()" in sql - assert "last_seen_at = NOW()" in sql + assert "scraped_at = statement_timestamp()" in sql + assert "last_seen_at = statement_timestamp()" in sql def test_kit_reconcile_bumps_scraped_at() -> None: - """kit dedup-drift reconcile UPDATE двигает scraped_at = NOW().""" + """kit dedup-drift reconcile UPDATE двигает scraped_at рядом с last_seen_at (#2731).""" db = _mock_db_reconcile(reconcile_id=88) lot = KitLot( source="avito", @@ -152,8 +158,8 @@ def test_kit_reconcile_bumps_scraped_at() -> None: kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66) sql = _find_sql(db, "SET dedup_hash") - assert "scraped_at = NOW()" in sql - assert "last_seen_at = NOW()" in sql + assert "scraped_at = statement_timestamp()" in sql + assert "last_seen_at = statement_timestamp()" in sql # ── Migration 161: retro-backfill scraped_at ────────────────────────────────── diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index 4c4c4d36..44ddd374 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -553,7 +553,16 @@ def save_listings( :predicted_price_min, :predicted_price_max, :price_trend, :price_previous_rub, :geo_precision, :card_hash, - NOW(), NOW() + -- scraped_at / last_seen_at — statement_timestamp(), НЕ NOW() (#2731). + -- NOW() == transaction_timestamp() замерзает на старте транзакции, а + -- save_listings коммитит один раз в конце всего batch'а → все строки + -- прогона несли ОДНУ метку (прод 2026-08-06: 219 строк — 1 метка). + -- statement_timestamp() двигается построчно (каждый upsert — свой + -- statement) и СТАБИЛЕН внутри statement'а, поэтому обе колонки + -- получают одно и то же значение. clock_timestamp() здесь нельзя: + -- он вычисляется на каждый вызов отдельно и развёл бы scraped_at и + -- last_seen_at на микросекунды — расхождение там, где его нет. + statement_timestamp(), statement_timestamp() ) -- Конфликт-арбитр — dedup_hash (sha256(source|source_id)). -- Для одного (source, source_id) формула даёт ОДИН dedup_hash, @@ -563,10 +572,12 @@ def save_listings( -- (source, source_id) НЕЛЬЗЯ: yandex/url-only дают source_id=NULL, -- а NULL не годится как conflict-target → дубли вернулись бы (#1773). ON CONFLICT (dedup_hash) DO UPDATE - SET last_seen_at = NOW(), + SET last_seen_at = statement_timestamp(), -- #2206: ре-подтверждение живым = свежий скрейп; без bump'а -- эстиматор (scraped_at > NOW()-14d) терял ~55% живого инвентаря. - scraped_at = NOW(), + -- #2731: обе метки — statement_timestamp() (см. VALUES выше); + -- их равенство сохраняется, потому что оно стабильно в statement'е. + scraped_at = statement_timestamp(), is_active = true, -- если цена изменилась — обновляем price_rub = EXCLUDED.price_rub, @@ -679,10 +690,12 @@ def save_listings( """ UPDATE listings SET dedup_hash = :dedup, - last_seen_at = NOW(), + last_seen_at = statement_timestamp(), -- #2206: ре-подтверждение живым = свежий скрейп; -- без bump'а эстиматор терял ~55% живого инвентаря. - scraped_at = NOW(), + -- #2731: statement_timestamp() — построчная метка, + -- одинаковая для обеих колонок (см. INSERT выше). + scraped_at = statement_timestamp(), is_active = true, price_rub = :price_rub, price_per_m2 = :ppm2, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/snapshot_writer.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/snapshot_writer.py index 3847d323..641caf61 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/snapshot_writer.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/snapshot_writer.py @@ -15,6 +15,21 @@ (data-modifying CTE в app/tasks/deactivate_stale_avito.py), не через этот per-row хелпер. +observed_at пишется `statement_timestamp()`, а НЕ `NOW()` (#2731). `NOW()` в PostgreSQL — +синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции, а save_listings +коммитит ОДИН раз в конце всего batch'а — то есть все снимки прогона получали ОДНУ метку. +Прод-замер 2026-08-06: run 3303 — 219 строк, 1 различная метка, при семи минутах работы; +run 3229 — 2643 строки на 55 меток (метка на save_listings-вызов, не на строку). +Цена — не фильтры свежести (14 суток против минут прогона), а разрешение во времени: +темп сбора и порядок внутри прогона по данным не восстановить (отсюда же #2701). + +Почему statement_timestamp(), а не clock_timestamp() как в #2702/#2718: этот INSERT — один +statement на строку (вызов в цикле save_listings), поэтому statement_timestamp() двигается +ПОСТРОЧНО и при этом СТАБИЛЕН внутри строки. clock_timestamp() дал бы разные значения даже +двум колонкам одного statement'а (прод-проверка: `clock_timestamp() = clock_timestamp()` → f, +`statement_timestamp() = statement_timestamp()` → t) — это сломало бы равенство +listings.scraped_at = last_seen_at, на которое опирается предикат миграции 161. + position_in_serp (#2674): параметр УДАЛЁН, колонка осталась и остаётся NULL. Это не оборванная проводка — задуманное отношение НЕВЫРАЗИМО в этой таблице. Позиция — свойство пары (объявление, конкретный прогон выдачи с конкретными @@ -87,7 +102,7 @@ def upsert_listing_snapshot( CAST(:price AS bigint), CAST(:ppm2 AS int), :status, - NOW() + statement_timestamp() ) ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET run_id = COALESCE(EXCLUDED.run_id, listings_snapshots.run_id), From 2d62b87cf35cafafe134459dc6b75a259c6d2fb4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 16:33:27 +0000 Subject: [PATCH 064/130] =?UTF-8?q?feat(tradein/payments):=20=D1=81=D1=85?= =?UTF-8?q?=D0=B5=D0=BC=D0=B0=20=D0=91=D0=94,=20=D0=BA=D0=BE=D0=BD=D1=84?= =?UTF-8?q?=D0=B8=D0=B3=20=D0=B8=20kill-switch=20=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=91=D0=B6=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=83=D1=80=D0=B0=20(#2732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 233_payments.sql (payments / payment_notifications / payment_entitlements), поля TBANK_* и PAYMENTS_ENABLED, fail-fast в lifespan. Бизнес-логики нет, контур выключен по умолчанию. По итогам deep review: UNIQUE NULLS NOT DISTINCT на обоих дедуп-ключах, payment_notifications.processed_at, payments.pd_erased_at, payments_lead_idx, CHECK на длину order_id, статусы сверены с официальной openapi.yaml (Confirm-2, v1.24). Co-authored-by: bot-backend Co-committed-by: bot-backend --- tradein-mvp/backend/app/core/config.py | 31 ++ tradein-mvp/backend/app/main.py | 13 + tradein-mvp/backend/data/sql/233_payments.sql | 305 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + 4 files changed, 350 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/233_payments.sql diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 337cd886..5d7267aa 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -918,5 +918,36 @@ class Settings(BaseSettings): # message_thread_id топика внутри support-группы, в который идут зеркала. telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID") + # ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ────────── + # См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит + # роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch. + # PAYMENTS_ENABLED=false (дефолт) держит контур выключенным полностью: + # ни один из последующих PR (C/D/E) не должен активироваться без явного + # включения в .env.runtime прод-стека. + tbank_terminal_key: str = Field(default="", validation_alias="TBANK_TERMINAL_KEY") + # Пароль терминала — участвует в подписи Token (Init) и проверке подписи + # входящих нотификаций. SecretStr по прецеденту auth_db_password (строка + # 197 выше): не должен всплыть в логах/repr/Sentry breadcrumbs. + tbank_password: SecretStr = Field(default=SecretStr(""), validation_alias="TBANK_PASSWORD") + tbank_api_base_url: str = Field( + default="https://securepay.tinkoff.ru", validation_alias="TBANK_API_BASE_URL" + ) + tbank_notification_url: str = Field(default="", validation_alias="TBANK_NOTIFICATION_URL") + tbank_success_url: str = Field(default="", validation_alias="TBANK_SUCCESS_URL") + tbank_fail_url: str = Field(default="", validation_alias="TBANK_FAIL_URL") + # "O" — одностадийная (оплата сразу), "T" — двухстадийная (холд + Confirm). + # Дефолт "T": выбрана схема с холдом (гибрид «Проба → холд → отчёт по + # ссылке», ядро — вариант B) — источник решения `mera-b2c-paid-flow- + # decision.md` §1 в корне репо, НЕ recon-док (тот сам по себе выбирает + # "O" — устарел этим решением). Не переставляй дефолт обратно на "O", не + # сверившись с decision-доком. + tbank_pay_type: Literal["O", "T"] = Field(default="T", validation_alias="TBANK_PAY_TYPE") + tbank_receipt_enabled: bool = Field(default=False, validation_alias="TBANK_RECEIPT_ENABLED") + tbank_taxation: str = Field(default="", validation_alias="TBANK_TAXATION") + tbank_ffd_version: str = Field(default="", validation_alias="TBANK_FFD_VERSION") + # Kill-switch всего контура. false — checkout/notify (появятся в PR-D) + # обязаны отказывать сразу, ничего не вызывая у T-Bank. + payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED") + settings = Settings() diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index bea049b8..7688fe0b 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -143,6 +143,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: get_auth_engine() logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован") + # Платёжный контур МЕРЫ (Т-Банк эквайринг, PR-B — схема-only). Роутер/сервис + # ещё не существуют (PR-D), но kill-switch и конфиг уже здесь: если кто-то + # включит PAYMENTS_ENABLED=true в .env.runtime раньше, чем заведёт + # TBANK_TERMINAL_KEY/TBANK_PASSWORD, лучше не поднимать контейнер вообще, + # чем молча остаться без терминала/пароля на боевом трафике оплат. + if settings.payments_enabled and ( + not settings.tbank_terminal_key or not settings.tbank_password.get_secret_value() + ): + raise RuntimeError( + "PAYMENTS_ENABLED=true, но TBANK_TERMINAL_KEY/TBANK_PASSWORD пусты — " + "задай оба в .env.runtime до включения платёжного контура" + ) + # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # Best-effort: failure does not abort startup, just logs. try: diff --git a/tradein-mvp/backend/data/sql/233_payments.sql b/tradein-mvp/backend/data/sql/233_payments.sql new file mode 100644 index 00000000..99dfeca9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/233_payments.sql @@ -0,0 +1,305 @@ +-- 233_payments.sql +-- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии +-- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»). +-- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на +-- месте по итогам review (статус HOLD), без ребейза номера. Дважды +-- переименована (git mv, история сохранена): 228 → 232 → 233. Номер 228 +-- заняли 228_scrape_proxies_browser_health.sql и 230_house_merge_log.sql +-- (влились в main); 229 и 231 занимает открытый PR #2547; 232 занял открытый +-- PR #2742 (`232_listings_observation_time_meaning.sql`). Урок: сверять номер +-- нужно не только по `forgejo/main`, но и по ВСЕМ открытым PR-веткам — ни один +-- из этих файлов сам себя в `_manifest_applied.txt` не пишет (мы пишем), из-за +-- чего коллизия обнаруживается только тестом `test_new_files_do_not_reuse_prefix` +-- уже после того, как чей-то PR смержен первым. +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- Этот PR — ТОЛЬКО схема + конфиг + kill-switch (`PAYMENTS_ENABLED=false` в +-- app/core/config.py, тот же PR). Роутера, статус-машины и обработчика +-- нотификаций здесь НЕТ (появятся в PR-D/E; PR-C — token/tbank_client/receipt — +-- уже смержен, схемы не касается). До PAYMENTS_ENABLED=true эти три таблицы +-- просто не пишутся никаким кодом; создание сейчас разблокирует параллельную +-- разработку PR-D без гонки миграций. +-- +-- ── WHAT ───────────────────────────────────────────────────────────────────── +-- payments — одна строка на попытку оплаты (Init → notify → +-- Confirm/Cancel). order_id — наш внутренний id, +-- уходит в T-Bank как OrderId (CHECK ≤50 симв. — +-- падать у себя, а не на /v2/Init); tbank_payment_id — +-- PaymentId из ответа Init, известен только ПОСЛЕ +-- вызова. pd_erased_at — см. отдельный блок ниже. +-- payment_notifications — append-only лог входящих вебхуков Т-Банка. +-- Идемпотентность нотификаций — это и есть +-- UNIQUE NULLS NOT DISTINCT(tbank_payment_id, status, +-- amount_kopecks, token): T-Bank шлёт AUTHORIZED и +-- CONFIRMED одновременно, дедуп через ON CONFLICT DO +-- NOTHING (сервисный код — PR-D). processed_at — +-- контракт fulfillment, см. блок ниже. Осознанно БЕЗ +-- CHECK на status: это сырой лог входящих данных, +-- узкий CHECK здесь означал бы, что недокументиро- +-- ванный/новый статус банка ломает запись самого +-- факта нотификации. +-- payment_entitlements — факт «что выдано за платёж» (доступ), НЕ кошелёк. +-- См. блок про amount/consumed ниже. +-- +-- ── ИДЕМПОТЕНТНОСТЬ UNIQUE-ключей: NULLS NOT DISTINCT (найдено на проде) ──── +-- Первая версия миграции использовала обычный UNIQUE на обоих ключах +-- дедупликации. В Postgres обычный UNIQUE считает NULL уникальным относительно +-- самого себя (NULL ≠ NULL) — при ref_id IS NULL / token IS NULL несколько +-- строк с одинаковым остальным набором колонок НЕ схлопываются. Это не +-- гипотетика: три одинаковых INSERT в payment_entitlements с ref_id IS NULL +-- дали три строки вместо одной при проверке на проде (до первого реального +-- применения этой миграции — воспроизведено отдельно). PG 16.4 (прод) умеет +-- `UNIQUE NULLS NOT DISTINCT` (с PG15) — NULL трактуется как равный NULL, +-- ровно то поведение, которое ожидает сервисный слой (ON CONFLICT DO NOTHING / +-- DO UPDATE). Применено к обоим дедуп-ключам ниже. +-- +-- ── СТАТУСЫ T-BANK (payments.status CHECK) ────────────────────────────────── +-- Источник истины — официальная OpenAPI-спека: +-- https://developer.tbank.ru/schemas/eacq/openapi.yaml (OpenAPI 3.0.2, v1.24), +-- схема `Confirm-2`, 24 значения. На странице /eacq/intro/developer/openapi +-- прямо сказано: при расхождении прозы и спеки приоритет у спеки — поэтому +-- ссылка на спеку, а не на человекочитаемые доки. +-- +-- ВАЖНО про GetState/CheckOrder (ручки, которыми реконсиляция PR-E читает +-- статус): в спеке их поле `Status` объявлено СВОБОДНОЙ строкой +-- (`maxLength: 20`, БЕЗ enum). То есть на ручках, которыми фактически питается +-- реконсиляция, банк словарь значений не фиксирует контрактно — наш CHECK +-- здесь строже, чем контракт поставщика. Это осознанный выбор (закрытый +-- список читается и валидируется проще, чем произвольная строка), а не +-- недосмотр; следующий читатель должен видеть, что этот CHECK может однажды +-- отвергнуть легитимный, но недокументированный `Confirm-2`-строкой статус — +-- см. контракт 'UNKNOWN' ниже. +-- +-- Сверка по `Confirm-2` относительно первой версии миграции: +-- - УБРАНЫ 'AUTHORIZED_AND_CHARGED' и 'RECEIPT_REGISTERED' — отсутствуют в +-- `Confirm-2`. Дополнительно у поля `Status` в спеке `maxLength: 20`, а +-- 'AUTHORIZED_AND_CHARGED' — 22 символа: физически не может быть значением +-- этого поля, не только "не найдено", а невозможно по контракту. +-- - ДОБАВЛЕНЫ '3DS_CHECKING' и '3DS_CHECKED' — есть в `Confirm-2`. +-- - НЕ добавлены 'ATTEMPTS_EXPIRED' и 'PAY_CHECKING' — отсутствуют в +-- `Confirm-2`, гипотеза не подтвердилась. +-- - 'PREAUTHORIZING' оставлен и подтверждён: есть в `Confirm-2`. (Ранее +-- редакция ссылалась на комментарий стороннего Go-клиента о том, что этот +-- статус будто бы убран из API — спекой это не подтверждается, комментарий +-- был неточным источником и снят.) +-- - ДОБАВЛЕНЫ пять пропущенных in-flight значений из `Confirm-2`: +-- 'CHECKING', 'CHECKED', 'PROCESSING', 'COMPLETING', 'COMPLETED'. Это +-- ровно те статусы, которые GetState/CheckOrder вернёт по зависшему +-- платежу — их читает реконсиляция (PR-E). Пропуск реального значения — +-- единственное опасное направление ошибки CHECK: не "лишний" статус +-- проскочит, а свой же CHECK отвергнет то, что банк реально прислал. +-- Порядок в списке ниже — по смысловой близости к соседним стадиям +-- (CHECKING/CHECKED рядом с 3DS_CHECKING/3DS_CHECKED, COMPLETING/COMPLETED +-- рядом с CONFIRMED), а не порядок из спеки — `Confirm-2` не гарантирует +-- порядок enum, для CHECK-констрейнта (проверка принадлежности множеству) +-- порядок значения не имеет. +-- - 'PARTIAL_REVERSED' и 'REFUND_FAILED' в `Confirm-2` ОТСУТСТВУЮТ. Оставлены +-- в CHECK как безвредный запас на случай появления в будущей версии API +-- (сам факт лишнего разрешённого значения в CHECK ничего не ломает — в +-- отличие от отсутствующего). Это отличается от предыдущей редакции +-- комментария, которая ошибочно утверждала, что сверка их "подтверждает": +-- не подтверждает, они не найдены в источнике истины. +-- +-- Контракт: если банк присылает статус вне списка ниже, ОБРАБОТЧИК +-- НОТИФИКАЦИЙ И ЗАДАЧА РЕКОНСИЛЯЦИИ (GetState/CheckOrder, PR-E) обязаны +-- писать в payments.status значение 'UNKNOWN' (не поднимать исключение, не +-- терять запись) — сырое тело нотификации в любом случае лежит целиком в +-- payment_notifications.body (у GetState/CheckOrder своего append-only лога +-- нет — если реконсиляция сама не сохранит сырой ответ, факт неизвестного +-- статуса останется только в payments.status='UNKNOWN' и её собственных логах). +-- INSERT/UPDATE payments с любым другим незнакомым значением упадёт на +-- CHECK — это специально: тихое искажение статуса хуже, чем громкий сбой +-- одной записи. +-- +-- ── payment_entitlements: без кредитно-кошельковой семантики ──────────────── +-- Первая версия несла amount/consumed (модель «кредиты/пакеты»). Явно +-- отвергнуто в `mera-b2c-paid-flow-decision.md` (§1): «Что отвергнуто явно: +-- кредиты/пакеты, роль customer, ... — цена ошибки в guard'е выше годовой +-- выручки этой воронки». Доставка купленного выбрана через capability-URL +-- (`/r/`, волна 3 §9 того же дока), а не через списание количества с +-- баланса. Таблица остаётся фактом «что выдано за платёж» (payment → kind +-- [+ ref_id]), без количественного состояния. Если модель когда-нибудь +-- реально понадобится — восстановить amount/consumed дешевле (ADD COLUMN), +-- чем сейчас снимать с них зависимости в PR-E, которого ещё нет. +-- +-- ── payments.pd_erased_at: покрытие purge-контура #2547 ───────────────────── +-- #2547 знает про PII в trade_in_leads/trade_in_estimates, но НЕ про payments +-- — эта таблица нового хранилища ПДн (customer_email/customer_phone) появится +-- вместе с PR-D. pd_erased_at NULL = ПДн не стирались; проставляется по +-- запросу субъекта на удаление — обнуляет customer_email/customer_phone, +-- фискально значимые поля (order_id, amount_kopecks, confirmed_at, +-- terminal_key и т.д.) остаются нетронутыми (обязательны для чека/сверки с +-- банком). Сам purge-job — вне scope этого PR (схема-only); колонку дешевле +-- завести сейчас, чем добавлять отдельной миграцией после того как PR-D +-- начнёт писать ПДн в эту таблицу. +-- +-- ── payment_notifications.processed_at: контракт fulfillment (для PR-D) ───── +-- Без этой колонки обработчик получается at-most-once по ОШИБКЕ: если процесс +-- упал ПОСЛЕ INSERT нотификации, но ДО выдачи товара (payment_entitlements / +-- инкремент квоты), ретрай банка увидит уже существующую строку через +-- ON CONFLICT DO NOTHING, ответит "OK" и товар не выдастся никогда — при этом +-- деньги у клиента уже списаны/захолдированы. Контракт для PR-D: обработка +-- нотификации считается завершённой (fulfillment состоялся) ТОЛЬКО когда +-- processed_at проставлен; сам факт наличия строки в payment_notifications +-- этого не гарантирует и не должен использоваться как признак «обработано». +-- +-- ── IDEMPOTENCY ────────────────────────────────────────────────────────────── +-- CREATE TABLE IF NOT EXISTS + DROP CONSTRAINT IF EXISTS перед ADD CONSTRAINT +-- (безопасный re-run на CHECK). Ничего не удаляет и не бэкфиллит. +-- +-- ── FK на trade_in_estimates / trade_in_leads ─────────────────────────────── +-- Обе таблицы проверены по факту (001_trade_in_estimates.sql, +-- 172_trade_in_leads.sql): id uuid PRIMARY KEY DEFAULT gen_random_uuid() в +-- обеих — FK безопасен, типы совпадают. ON DELETE SET NULL — по образцу +-- уже существующего trade_in_leads.estimate_id (172_trade_in_leads.sql:11): +-- обе колонки здесь опциональные бизнес-ссылки, а не владеющая связь, удаление +-- estimate/lead не должно ронять запись о платеже. +-- +-- Dependencies: 001_trade_in_estimates.sql, 172_trade_in_leads.sql. +-- Apply after: 230_house_merge_log.sql. + +BEGIN; + +-- ───────────────────────────────────────────────────────────────────────── +-- payments +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + order_id text NOT NULL UNIQUE CHECK (char_length(order_id) <= 50), + tbank_payment_id text UNIQUE, -- PaymentId из ответа Init (NULL до Init) + terminal_key text NOT NULL, + product_code text NOT NULL, -- что продали (product_code, не цена из тела запроса) + amount_kopecks bigint NOT NULL CHECK (amount_kopecks > 0), + currency text NOT NULL DEFAULT 'RUB', + status text NOT NULL DEFAULT 'NEW', + payment_url text, + + created_by text, -- username (X-Authenticated-User), NULL если анонимный checkout + estimate_id uuid REFERENCES trade_in_estimates(id) ON DELETE SET NULL, + lead_id uuid REFERENCES trade_in_leads(id) ON DELETE SET NULL, + customer_email text, + customer_phone text, + pd_erased_at timestamptz, -- см. блок про purge-контур #2547 в шапке файла + + error_code text, + error_message text, + init_response jsonb, -- сырой ответ T-Bank /v2/Init, для дебага + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + authorized_at timestamptz, + confirmed_at timestamptz, + refunded_at timestamptz +); + +ALTER TABLE payments DROP CONSTRAINT IF EXISTS payments_status_check; +ALTER TABLE payments + ADD CONSTRAINT payments_status_check + CHECK (status IN ( + 'NEW', + 'FORM_SHOWED', + 'DEADLINE_EXPIRED', + 'CANCELED', + 'PREAUTHORIZING', + 'AUTHORIZING', + 'AUTHORIZED', + 'AUTH_FAIL', + 'REJECTED', + '3DS_CHECKING', + '3DS_CHECKED', + 'CHECKING', + 'CHECKED', + 'PROCESSING', + 'CONFIRMING', + 'CONFIRMED', + 'COMPLETING', + 'COMPLETED', + 'REVERSING', + 'PARTIAL_REVERSED', + 'REVERSED', + 'REFUNDING', + 'PARTIAL_REFUNDED', + 'REFUNDED', + 'REFUND_FAILED', + 'UNKNOWN' + )); + +CREATE INDEX IF NOT EXISTS payments_status_created_idx ON payments (status, created_at); +CREATE INDEX IF NOT EXISTS payments_created_by_idx ON payments (created_by); +CREATE INDEX IF NOT EXISTS payments_estimate_idx ON payments (estimate_id); +-- lead_id имеет FK ON DELETE SET NULL — без индекса Postgres делает seq scan +-- по payments на каждый DELETE FROM trade_in_leads (проверка "нет ли ссылок" +-- перед SET NULL). #2547 вводит пакетное физическое удаление лидов — без +-- индекса это N seq scan'ов по payments на один batch-прогон purge-джобы. +CREATE INDEX IF NOT EXISTS payments_lead_idx ON payments (lead_id); + +COMMENT ON TABLE payments IS + 'Платёжный контур МЕРЫ (Т-Банк эквайринг). Одна строка на попытку оплаты. ' + 'Контур выключен по умолчанию — см. PAYMENTS_ENABLED в app/core/config.py.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- payment_notifications — append-only, идемпотентность входящих вебхуков +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payment_notifications ( + id bigserial PRIMARY KEY, + + order_id text, + tbank_payment_id text, + status text, -- сырой статус из тела, без CHECK (см. WHY выше) + amount_kopecks bigint, + token text, + token_valid boolean NOT NULL, + body jsonb NOT NULL, -- полное тело нотификации как есть + + received_at timestamptz NOT NULL DEFAULT now(), + -- Контракт fulfillment для PR-D — см. подробный блок в шапке файла. + -- NULL = обработка (выдача товара) ещё не завершена или не начиналась; + -- проставляется сервисным кодом ПОСЛЕ успешной выдачи, не в момент INSERT. + processed_at timestamptz, + + -- Дедуп-ключ идемпотентности (recon §3 п.4): T-Bank шлёт AUTHORIZED и + -- CONFIRMED одновременно для одностадийной оплаты; ON CONFLICT DO NOTHING + -- в сервисном коде (PR-D) значит "уже обработано". NULLS NOT DISTINCT + -- (см. блок в шапке файла) — без него NULL в token/tbank_payment_id не + -- считался бы дублем самого себя, и дедуп молча переставал бы работать + -- ровно в вырожденном случае, для которого он и нужен. + UNIQUE NULLS NOT DISTINCT (tbank_payment_id, status, amount_kopecks, token) +); + +COMMENT ON TABLE payment_notifications IS + 'Append-only лог входящих вебхуков T-Bank. Идемпотентность через UNIQUE ' + 'NULLS NOT DISTINCT(tbank_payment_id, status, amount_kopecks, token) + ' + 'ON CONFLICT DO NOTHING. processed_at — контракт "выдача состоялась" для PR-D.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- payment_entitlements — что выдано за платёж (факт, не кошелёк) +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payment_entitlements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + payment_id uuid NOT NULL REFERENCES payments(id), + subject text NOT NULL, -- username или anon-token, кому выдано + kind text NOT NULL, -- 'pdf_report' | 'report_link' | ... + ref_id uuid, -- estimate_id для разового отчёта, NULL если не применимо + + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + + -- Гарантия "выдали один раз" (recon §3). NULLS NOT DISTINCT (см. блок в + -- шапке файла) — без него при ref_id IS NULL несколько строк с одинаковым + -- (payment_id, kind) НЕ считались бы дублем этим UNIQUE, что и + -- воспроизвелось на проде до первого применения миграции. + UNIQUE NULLS NOT DISTINCT (payment_id, kind, ref_id) +); + +COMMENT ON TABLE payment_entitlements IS + 'Факт "что выдано за платёж" (доступ), НЕ кредитный кошелёк — amount/' + 'consumed сознательно отсутствуют, см. mera-b2c-paid-flow-decision.md §1 ' + '(модель кредитов/пакетов отвергнута явно). UNIQUE NULLS NOT DISTINCT ' + '(payment_id, kind, ref_id) страхует fulfillment (PR-E) от повторной ' + 'выдачи по одной нотификации.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 0fb41dd0..7d71c6d7 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -230,3 +230,4 @@ # Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. +233_payments.sql From 9f51c98ff47ac375aa7586bfadbe0cb622ccf50b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 16:35:29 +0000 Subject: [PATCH 065/130] =?UTF-8?q?fix(tradein/matching):=20listing=5Fsour?= =?UTF-8?q?ces=20=D0=B4=D0=B0=D1=82=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D1=81=D1=82=D1=80=D0=BE=D1=87=D0=BD=D0=BE?= =?UTF-8?q?,=20=D0=B0=20=D0=BD=D0=B5=20=D1=81=D1=82=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20(#2731)=20(#2743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/matching/listings.py | 26 +++- .../test_2731_listing_sources_timestamps.py | 126 ++++++++++++++++++ 2 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2731_listing_sources_timestamps.py diff --git a/tradein-mvp/backend/app/services/matching/listings.py b/tradein-mvp/backend/app/services/matching/listings.py index f2b94f72..ceaae20c 100644 --- a/tradein-mvp/backend/app/services/matching/listings.py +++ b/tradein-mvp/backend/app/services/matching/listings.py @@ -246,7 +246,23 @@ def _upsert_listing_source( source_url: str | None, source_data: dict | None, ) -> None: - """Insert or refresh listing_sources row for this source+ext_id.""" + """Insert or refresh listing_sources row for this source+ext_id. + + Отметки времени — statement_timestamp(), НЕ NOW() (#2731). Этот upsert вызывается + ПОСТРОЧНО из save_listings (hook `_link_listing_to_house`), а транзакция batch'а + коммитится один раз в конце, поэтому NOW() (== transaction_timestamp) давал одну + метку на весь вызов: прод-замер 2026-08-06 — 219 строк на 1 метку в 11:00, + 235/1 в 10:00, 297/1 в 09:00, и так каждый час. + + Чинится вместе с listings.scraped_at/last_seen_at, а не отдельно: сегодня + listings.last_seen_at = listing_sources.last_seen_at у 100% пар (2407 из 2407 за + сутки) именно потому, что обе колонки берут одну транзакционную метку. Почини + только одну — вторая осталась бы замороженной на старте batch'а, и расхождение + выросло бы с миллисекунд (честная разница двух записей) до длительности прогона. + + Все три колонки пишутся ОДНИМ statement'ом, поэтому statement_timestamp() даёт им + одинаковое значение; clock_timestamp() развёл бы их на микросекунды. + """ raw = json.dumps(source_data) if source_data is not None else None db.execute( text(""" @@ -257,15 +273,15 @@ def _upsert_listing_source( price_rub, area_m2, floor, rooms_count, raw_payload ) VALUES ( CAST(:lid AS bigint), :s, :e, - CAST(:c AS real), :m, NOW(), NOW(), - :url, NOW(), + CAST(:c AS real), :m, statement_timestamp(), statement_timestamp(), + :url, statement_timestamp(), CAST(:p AS bigint), CAST(:a AS numeric), :fl, :rc, CAST(:raw AS jsonb) ) ON CONFLICT (ext_source, ext_id) DO UPDATE SET confidence = GREATEST(EXCLUDED.confidence, listing_sources.confidence), - last_seen_at = NOW(), - last_scraped_at = NOW(), + last_seen_at = statement_timestamp(), + last_scraped_at = statement_timestamp(), price_rub = COALESCE(EXCLUDED.price_rub, listing_sources.price_rub), area_m2 = COALESCE(EXCLUDED.area_m2, listing_sources.area_m2), floor = COALESCE(EXCLUDED.floor, listing_sources.floor), diff --git a/tradein-mvp/backend/tests/test_2731_listing_sources_timestamps.py b/tradein-mvp/backend/tests/test_2731_listing_sources_timestamps.py new file mode 100644 index 00000000..4bcdee4f --- /dev/null +++ b/tradein-mvp/backend/tests/test_2731_listing_sources_timestamps.py @@ -0,0 +1,126 @@ +"""#2731 (вторая половина): listing_sources датируется построчно, как и listings. + +`_upsert_listing_source` вызывается ПОСТРОЧНО из save_listings (hook +`_link_listing_to_house`), а транзакция batch'а коммитится один раз в конце. С `NOW()` +(== `transaction_timestamp()`) все строки вызова получали одну метку — прод-замер +2026-08-06 по часам: 219 строк / 1 метка, 235 / 1, 297 / 1, 150 / 1 … + +Почему это чинится ВМЕСТЕ с listings, а не отдельно: сегодня +`listings.last_seen_at = listing_sources.last_seen_at` у 100% пар (прод: 2407 из 2407 за +сутки) — ровно потому, что обе колонки берут одну транзакционную метку. Если починить +только listings, вторая колонка осталась бы замороженной на старте batch'а, и расхождение +выросло бы с миллисекунд (честная разница двух соседних записей) до длительности прогона. + +Фальсификация: на старом коде (`NOW()`) оба теста дают одну метку на все строки. +""" + +from __future__ import annotations + +import os +import re +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services.matching.listings import _upsert_listing_source + +# Колонки времени этого писателя. Пишутся ОДНИМ statement'ом → обязаны совпадать. +_COLS = ("matched_at", "last_seen_at", "last_scraped_at") +_STEP_SEC = 0.6 # три строки → 1.8 с работы одной транзакции + + +class _FakePg: + """Модель PostgreSQL на две функции времени (см. test_2731_observation_timestamps). + + `now()` замерзает на старте транзакции, `statement_timestamp()` — на старте запроса; + `clock_timestamp()` вычисляется на каждый вызов, поэтому три колонки одного + statement'а разъехались бы. + """ + + def __init__(self) -> None: + self.wall = 0.0 + self.tx_start: float | None = None + self.stmt_start = 0.0 + self._clock_calls = 0 + self.rows: list[dict[str, float]] = [] + + def _value(self, func: str) -> float: + if func == "now": + assert self.tx_start is not None + return self.tx_start + if func == "statement_timestamp": + return self.stmt_start + if func == "clock_timestamp": + self._clock_calls += 1 + return self.stmt_start + self._clock_calls * 1e-6 + raise AssertionError(f"неизвестная функция времени: {func}") + + def execute(self, stmt: Any, params: Any = None) -> None: + if self.tx_start is None: + self.tx_start = self.wall + self.stmt_start = self.wall + self.wall += _STEP_SEC + sql = re.sub(r"--[^\n]*", "", str(stmt)) + if "INSERT INTO listing_sources" not in sql: + return None + # VALUES: отметки позиционные; ON CONFLICT: именованные. Берём обе формы. + cols = re.search( + r"INSERT INTO listing_sources\s*\((.*?)\)\s*VALUES\s*\((.*?)\)\s*\n", sql, re.S + ) + row: dict[str, float] = {} + if cols is not None: + names = [c.strip() for c in cols.group(1).replace("\n", " ").split(",")] + vals = [v.strip() for v in cols.group(2).replace("\n", " ").split(",")] + for name, val in zip(names, vals, strict=False): + f = re.fullmatch(r"(\w+)\s*\(\s*\)", val) + if name in _COLS and f is not None: + row[name] = self._value(f.group(1).lower()) + for col in _COLS: # ON CONFLICT DO UPDATE SET = () + m = re.search(rf"\b{col}\s*=\s*(\w+)\s*\(\s*\)", sql) + if m is not None: + row.setdefault(col, self._value(m.group(1).lower())) + self.rows.append(row) + return None + + +def _write(db: _FakePg, n: int = 3) -> None: + for i in range(n): + _upsert_listing_source( + db, + listing_id=i, + ext_source="cian", + ext_id=str(i), + method="source_link", + confidence=1.0, + price_rub=5_000_000, + area_m2=42.0, + floor=3, + rooms_count=1, + source_url=f"https://ekb.cian.ru/sale/flat/{i}/", + source_data=None, + ) + + +def test_listing_source_marks_differ_per_row() -> None: + """Писатель, отработавший дольше секунды, оставляет РАЗЛИЧНЫЕ метки у строк.""" + db = _FakePg() + _write(db) + + marks = [row["last_seen_at"] for row in db.rows] + assert len(marks) == 3 + assert max(marks) - min(marks) > 1.0, "писатель отработал дольше секунды" + assert len(set(marks)) == len(marks), "у строк одной транзакции обязаны быть разные метки" + + +def test_all_three_marks_of_one_statement_agree() -> None: + """matched_at / last_seen_at / last_scraped_at пишутся одним statement'ом — и совпадают. + + `clock_timestamp()` развёл бы их на микросекунды: модель БД это воспроизводит, + поэтому такая «починка» провалит тест. + """ + db = _FakePg() + _write(db) + + for row in db.rows: + assert set(row) == set(_COLS), "все три отметки обязаны быть записаны" + assert len(set(row.values())) == 1, "отметки одного statement'а обязаны совпадать" From 881730bf206ca4c2fb5e2c41fa6e5e1fa8018434 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 19:49:13 +0300 Subject: [PATCH 066/130] =?UTF-8?q?fix(tradein/privacy):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D1=8F=D1=82=D1=8C=20B2B-=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B2=20purge=20+=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B8=D1=82=D1=8C=20=D1=82=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=20=D0=B2=20=D0=B4=D1=80=D1=83=D0=B3=D0=BE?= =?UTF-8?q?=D0=BC=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20erasure=20(#2547)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review HIGH: purge_expired_trade_in_data удалял trade_in_estimates по expires_at без разбора B2B/B2C -- эта колонка TTL ссылки/PDF, а не срок хранения строки, и её единообразно проставляет каждой оценке estimator.py. Прод-аудит: 1040/1057 строк просрочены, 911 из них у пилотов (admin, kopylov, brusnika, praktika, pilottest, admintest, user1). DELETE теперь ограничен created_by IS NULL -- ровно анонимная B2C-популяция (129 строк). Докстринг миграции 231 переписан: явные цифры аудита, необратимость, чек-лист (свежий SELECT count + один supervised прогон) перед enable. Deep-review MEDIUM: erase_person_data сравнивал phone точным =, а lead.py сохраняет номер как прислали (без нормализации, намеренно) -- разное форматирование одного и того же номера не находилось, 0 строк удалялось, но ответ всё равно был 200 "данные удалены". Сравнение переведено на regexp_replace(x, '\D', '', 'g') с обеих сторон. Оба фикса проверены живьём (throwaway Postgres 16 в docker, вне обычного mock-only CI-лейна): без гварда пилотская строка удалялась вместе с анонимной; без нормализации разноформатный телефон не находился. С фиксами -- находит/не находит ровно как задумано. Добавлены self-skipping live-DB тесты (паттерн test_house_dedup_merge.py::_live_session) плюс статические SQL-guard тесты. --- .../backend/app/services/data_erasure.py | 26 ++++- .../app/tasks/purge_expired_trade_in_data.py | 22 ++++ .../sql/231_trade_in_privacy_retention.sql | 32 ++++- .../backend/tests/test_data_erasure.py | 90 +++++++++++++++ .../tests/test_purge_expired_trade_in_data.py | 109 ++++++++++++++++++ 5 files changed, 275 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/services/data_erasure.py b/tradein-mvp/backend/app/services/data_erasure.py index b2053dae..28f2b098 100644 --- a/tradein-mvp/backend/app/services/data_erasure.py +++ b/tradein-mvp/backend/app/services/data_erasure.py @@ -16,6 +16,11 @@ WHO CAN BE IDENTIFIED, HONESTLY: * `estimate_ids` -- if they still have the link/PDF from their estimate (the UUID in the URL/QR-code IS their proof of "this is mine"). * `phone` -- if they left a contact-request lead with that phone. + Matched by NORMALIZED DIGITS ONLY (regexp_replace strips everything + but 0-9 on both sides), not an exact string: lead.py stores + `payload.phone` exactly as typed (no E.164 normalization, by + design), so the caller's "+7 999 123-45-67" must still find a row + saved as "89991234567" or any other formatting of the same digits. * `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own Telegram chat id -- not guessable/spoofable by a third party the way a name or IP would be). @@ -119,13 +124,32 @@ def erase_person_data( # 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по # телефону (лид мог быть оставлен без attach к оценке вовсе). + # + # ⚠️ Телефон сравнивается по НОРМАЛИЗОВАННЫМ цифрам, не литералом + # (deep-review 2026-08-06, MEDIUM). app/api/v1/lead.py сохраняет + # payload.phone КАК ПРИСЛАЛИ (намеренно -- полная E.164-нормализация + # вне scope MVP, см. lead.py::_PHONE_PATTERN), т.е. одна и та же + # строка может лежать в БД как "+7 999 123-45-67" ИЛИ "89991234567" + # ИЛИ любой другой форматировкой той же маски. Точное `phone = :phone` + # находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ + # формат, каким когда-то ввёл номер -- почти никогда так. Раньше это + # молча удаляло 0 строк и всё равно возвращало 200 "данные удалены": + # для 152-ФЗ ложное подтверждение удаления хуже честной ошибки. + # `regexp_replace(x, '\\D', '', 'g')` с ОБЕИХ сторон сравнения снимает + # форматирование (пробелы/скобки/дефисы/+) и сравнивает голые цифры. + # Параметр -- CAST(:phone AS text), НЕ конкатенация (psycopg v3 / SQL + # injection convention, .claude/rules/backend.md). ids_param = [str(i) for i in all_estimate_ids] result = db.execute( text( """ DELETE FROM trade_in_leads WHERE estimate_id = ANY(CAST(:ids AS uuid[])) - OR phone = :phone + OR ( + CAST(:phone AS text) IS NOT NULL + AND regexp_replace(phone, '\\D', '', 'g') + = regexp_replace(CAST(:phone AS text), '\\D', '', 'g') + ) """ ), {"ids": ids_param, "phone": phone}, diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index 32dba363..420911ec 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -23,6 +23,27 @@ WHAT: it has its OWN retention clock (trade_in_leads.expires_at) and its own PII (phone), purged independently below. +⚠️ trade_in_estimates DELETE is scoped to `created_by IS NULL` (deep-review finding, + 2026-08-06, HIGH): `expires_at` on this table is set UNCONDITIONALLY for every + estimate, B2B pilot or anonymous (`now + settings.trade_in_estimate_retention_hours`, + see app/services/estimator.py) -- it is a TTL on the ESTIMATE LINK/PDF staying + resolvable (GET /estimate/{id}: 404 past expiry, PDF export: 410 past expiry), NOT a + declared retention deadline for the ROW. B2B pilots' consent is closed by contract + (see migration 229's `consent` column asymmetry: NULL for `created_by IS NOT NULL`, + the exact same B2B-vs-B2C split used here) and their estimates are the live basis for + /trade-in/history, /team/employees/{id}/history and the cache-stats dashboards (see + app/api/v1/trade_in.py, app/api/v1/team.py) -- deleting them past a 24h *link* TTL + would be silent, irreversible data loss of pilots' own operational data, not a + privacy-driven cleanup. Audited against prod on 2026-08-06: of 1057 rows, 1040 had + already crossed `expires_at`, and 911 of THOSE belonged to named pilots + (`created_by` set: admin, kopylov, brusnika, praktika, pilottest, admintest, user1). + Without the `created_by IS NULL` guard, one unattended run at + `batch_size=500, max_batches=20` (the defaults) would have deleted essentially the + whole table. `created_by IS NULL` is the honest B2C population -- 129 rows in that + same audit. trade_in_leads has no `created_by` column (never had a B2B/B2C split -- + its own `expires_at` really is a 180-day retention deadline, not a link TTL, see + migration 231) so its DELETE below is intentionally NOT scoped the same way. + BATCHING (не единый DELETE по всей таблице): Each table is drained in batches of `batch_size` rows (default settings.trade_in_purge_batch_size), each batch its OWN statement + its OWN commit @@ -59,6 +80,7 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text( WHERE id IN ( SELECT id FROM trade_in_estimates WHERE expires_at < NOW() + AND created_by IS NULL ORDER BY expires_at LIMIT CAST(:batch_size AS int) ) diff --git a/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql b/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql index c78c7780..b3a3995b 100644 --- a/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql +++ b/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql @@ -34,9 +34,35 @@ -- 175_scrape_schedules_seed_domclick_detail_backfill.sql): это ПЕРВАЯ -- автоматическая задача физического DELETE персональных данных в trade-in — -- заслуживает supervised первого прогона (смотри логи/counters вручную) --- перед тем, как доверить её расписанию. Включение — отдельный ручной шаг --- (UPDATE scrape_schedules SET enabled=true WHERE source= --- 'purge_expired_trade_in_data'). +-- перед тем, как доверить её расписанию. +-- +-- ⚠️ ПЕРЕД ВКЛЮЧЕНИЕМ (deep-review 2026-08-06, HIGH — читай целиком, не +-- только команду в конце): задача удаляет строки trade_in_estimates +-- физически и НЕОБРАТИМО. DELETE ограничен `created_by IS NULL` — только +-- анонимные B2C-оценки (см. докстринг app/tasks/purge_expired_trade_in_ +-- data.py, раздел про асимметрию expires_at = TTL ссылки/PDF, а не срок +-- хранения строки) — строки B2B-пилотов (`created_by` задан) задача НЕ +-- трогает НИКОГДА, что бы ни стояло в expires_at. Аудит на проде на момент +-- этой правки: из 1057 строк trade_in_estimates 1040 уже просрочены +-- (expires_at < NOW()), но ТОЛЬКО 129 из них имеют created_by IS NULL +-- (реальная B2C-популяция под удаление) — остальные 911 принадлежат +-- пилотам (admin/kopylov/brusnika/praktika/pilottest/admintest/user1) и +-- гвардом защищены от удаления. Эти цифры УСТАРЕЮТ — не включай задачу по +-- их памяти. Перед `UPDATE scrape_schedules SET enabled=true WHERE +-- source='purge_expired_trade_in_data'`: +-- 1. Свежий `SELECT count(*) FROM trade_in_estimates WHERE expires_at +-- < NOW() AND created_by IS NULL` — если число на порядок больше +-- 129 (или created_by-гвард почему-то отсутствует в текущем коде +-- задачи) — СТОП, разберись, прежде чем включать. +-- 2. Прогони задачу вручную ОДИН раз (app/tasks/ +-- purge_expired_trade_in_data.py, синхронная функция) и сверь +-- counters с п.1 (задача логирует batch/running_total через +-- logger.info в _drain_expired) — supervised первый прогон, не +-- включение вслепую. +-- 3. Только после совпадения счётчиков — enable в scrape_schedules; +-- расписание подхватит на следующем тике планировщика (крутится в +-- контейнере tradein-scraper, не tradein-backend). +-- Включение — отдельный ручной шаг, эта миграция его НЕ делает. -- -- IDEMPOTENCY / SAFETY: -- - ADD COLUMN IF NOT EXISTS + UPDATE ... WHERE expires_at IS NULL (no-op на diff --git a/tradein-mvp/backend/tests/test_data_erasure.py b/tradein-mvp/backend/tests/test_data_erasure.py index ef2ec46e..9ca1471d 100644 --- a/tradein-mvp/backend/tests/test_data_erasure.py +++ b/tradein-mvp/backend/tests/test_data_erasure.py @@ -15,6 +15,7 @@ Covers app/services/data_erasure.py: from __future__ import annotations import os +import re from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -137,6 +138,26 @@ def test_erase_by_phone_only_touches_only_leads() -> None: assert params["ids"] == [] +def test_phone_delete_normalizes_digits_on_both_sides() -> None: + """Regression guard for the deep-review MEDIUM finding (2026-08-06): + lead.py stores phone exactly as typed (no E.164 normalization, by + design), so a differently-formatted-but-same-number erasure request + ('+7 999 123-45-67' vs a stored '89991234567') must still match. The old + exact `phone = :phone` comparison silently deleted 0 rows and still + returned HTTP 200 'erased' -- worse than an honest error under 152-ФЗ. + Both sides of the comparison must go through regexp_replace, and the + literal-equality path must be gone.""" + db = MagicMock() + db.execute.side_effect = [_Result(rowcount=1)] + + data_erasure.erase_person_data(db, phone="+7 999 123-45-67") + + sql = _sql_of(db.execute.call_args_list[0]) + assert sql.count("regexp_replace") == 2 + assert "phone = :phone" not in sql + assert not re.search(r":\w+::", sql) # psycopg v3 CAST trap + + def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None: """Anonymous person with NO username, NO estimate link, NO lead phone -- but they DID message @MERAsupport_bot -- can still be identified by their own @@ -158,3 +179,72 @@ def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None: calls = db.execute.call_args_list assert "DELETE FROM tg_support_users" in _sql_of(calls[-1]) assert calls[-1].args[1]["chat_id"] == 123456789 + + +# ── Optional real-Postgres behavioural test (self-skips without a DB) ────────── +# Same pattern as tests/test_house_dedup_merge.py::_live_session -- CI runs the +# mock-only lane (DATABASE_URL is a placeholder), so this self-skips there; it +# only executes with a real reachable Postgres (e.g. local dev DB). + + +def _live_session() -> Any | None: + """Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None.""" + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + from sqlalchemy import text as _t + + conn.execute(_t("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_finds_differently_formatted_number() -> None: + """End-to-end on a real DB: a lead stored with phone EXACTLY as typed + ('89991234567', no separators) must still be found and deleted when the + erasure requester supplies the SAME digits in a DIFFERENT format + ('8 (999) 123-45-67') -- proves the regexp_replace normalization fix + actually matches, not just that the SQL text contains the function name.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + out = data_erasure.erase_person_data(db, phone="8 (999) 123-45-67") + + assert out["trade_in_leads_deleted"] == 1 + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 0 + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index a953c278..07a85aa9 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -18,6 +18,7 @@ import os import re from pathlib import Path from typing import Any +from uuid import uuid4 import pytest @@ -149,6 +150,35 @@ def test_leads_sql_is_delete_not_update() -> None: assert not re.search(r":\w+::", sql) +def test_estimates_sql_scopes_delete_to_anonymous_created_by_null() -> None: + """Regression guard for the deep-review HIGH finding (2026-08-06): + trade_in_estimates.expires_at is set UNCONDITIONALLY on every estimate + (B2B pilot or anonymous) as a TTL on the estimate LINK/PDF staying + resolvable, NOT a declared row-retention deadline -- see the task's + module docstring. Without this guard, prod audit showed 1040/1057 rows + past expires_at, 911 of them belonging to named pilots (admin, kopylov, + brusnika, praktika, pilottest, admintest, user1); one unattended run at + the seeded defaults (batch_size=500, max_batches=20) would have deleted + essentially the whole table, including pilots' own operational history + (/trade-in/history, /team/employees/{id}/history, cache-stats all read + trade_in_estimates without an expires_at filter). The DELETE must stay + scoped to created_by IS NULL -- the honest B2C population (129 rows in + that same audit).""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "created_by IS NULL" in sql + assert not re.search(r":\w+::", sql) + + +def test_leads_sql_has_no_created_by_guard() -> None: + """trade_in_leads has NO created_by column at all (never had a B2B/B2C + split) -- its expires_at IS a genuine 180-day retention deadline (see + migration 231), not a link/PDF-access TTL like trade_in_estimates. This + documents the asymmetry explicitly so a future 'fix' doesn't bolt a + created_by filter onto a table that doesn't have the column.""" + sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text + assert "created_by" not in sql + + def test_sql_does_not_delete_whole_table_unbounded() -> None: """Neither statement is a bare `DELETE FROM table` -- both scope via a subselect + LIMIT batch.""" @@ -230,3 +260,82 @@ def test_migration_231_seeds_purge_schedule_disabled_by_default() -> None: def test_migration_231_no_psycopg_trap() -> None: sql = _MIGRATION_231.read_text("utf-8") assert not re.search(r":\w+::", sql) + + +# ── Optional real-Postgres behavioural test (self-skips without a DB) ────────── +# Same pattern as tests/test_house_dedup_merge.py::_live_session -- CI runs the +# mock-only lane (DATABASE_URL is a placeholder), so this self-skips there; it +# only executes with a real reachable Postgres (e.g. local dev DB). + + +def _live_session() -> Any | None: + """Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None.""" + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + from sqlalchemy import text as _t + + conn.execute(_t("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_purge_deletes_only_anonymous_expired_estimates() -> None: + """End-to-end on a real DB: one expired B2B row (created_by set) and one + expired anonymous row (created_by IS NULL) both exist -- a real run of + purge_expired_trade_in_data must delete ONLY the anonymous one. This is + the exact scenario the deep-review HIGH finding (2026-08-06) flagged: + without the created_by IS NULL guard, the pilot row would also be gone.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + anon_id = uuid4() + pilot_id = uuid4() + try: + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by) VALUES " + "(CAST(:anon_id AS uuid), 'purge-test аноним', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL), " + "(CAST(:pilot_id AS uuid), 'purge-test пилот', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', 'pytest_purge_guard')" + ), + {"anon_id": str(anon_id), "pilot_id": str(pilot_id)}, + ) + db.commit() + + task_mod.purge_expired_trade_in_data(db, run_id=999999999, batch_size=100, max_batches=5) + + remaining_ids = { + str(r) + for r in db.execute( + _t("SELECT id FROM trade_in_estimates " "WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(anon_id), str(pilot_id)]}, + ) + .scalars() + .all() + } + assert str(anon_id) not in remaining_ids, "anonymous expired row must be purged" + assert str(pilot_id) in remaining_ids, "B2B pilot row must survive despite expires_at" + finally: + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(anon_id), str(pilot_id)]}, + ) + db.commit() + db.close() From a091da1aa85fbe12fdb1bd46cd161eba68657025 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 16:57:28 +0000 Subject: [PATCH 067/130] =?UTF-8?q?fix(tests):=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B8=20=D0=B2=D1=8B=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8F=D0=BB=D0=B8=D1=81=D1=8C=20=D0=BD=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=83=20(#2744)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/api/v1/test_admin_cadastre.py | 44 ++----------------- .../tests/api/v1/test_admin_ekburg_permits.py | 16 ------- .../scrapers/test_ekb_ppt_tep_parser.py | 20 ++++++--- backend/tests/test_admin_weight_profiles.py | 38 ---------------- .../backend/tests/test_gar_flats_loader.py | 10 ++++- 5 files changed, 24 insertions(+), 104 deletions(-) diff --git a/backend/tests/api/v1/test_admin_cadastre.py b/backend/tests/api/v1/test_admin_cadastre.py index 3df39895..189ecba1 100644 --- a/backend/tests/api/v1/test_admin_cadastre.py +++ b/backend/tests/api/v1/test_admin_cadastre.py @@ -5,32 +5,16 @@ from __future__ import annotations -# scrape_cadastre зависит от app.scrapers.nspd_bulk_client (PR 2/5). -# Используем importlib.util.find_spec вместо прямого import — иначе -# `import app.workers...` пересоздаёт `app` как Python package и -# перебивает FastAPI instance, привязанный в строке выше → AttributeError -# на app.dependency_overrides. -import importlib.util +# NB: `import app.workers...` здесь запрещён — он пересоздаёт `app` как Python +# package и перебивает FastAPI instance, привязанный ниже → AttributeError на +# app.dependency_overrides. Патчим воркер строкой, через patch("app.workers..."). from typing import Any from unittest.mock import MagicMock, patch -import pytest from fastapi.testclient import TestClient from app.main import app -_SCRAPE_CADASTRE_AVAILABLE = ( - importlib.util.find_spec("app.workers.tasks.scrape_cadastre") is not None -) - -requires_scrape_cadastre = pytest.mark.skipif( - not _SCRAPE_CADASTRE_AVAILABLE, - reason=( - "app.scrapers.nspd_bulk_client отсутствует — нужно смержить main (PR #170). " - "Команда: git rebase origin/main" - ), -) - # Токен для тестов (не реальный) ADMIN_TOKEN = "test-admin-token" ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} @@ -113,7 +97,6 @@ def _make_sample_job_row() -> dict[str, Any]: # ── Tests ──────────────────────────────────────────────────────────────────── -@requires_scrape_cadastre @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_pilot_returns_job_id() -> None: """POST /cadastre/jobs scope=pilot → job_id + targets_total.""" @@ -144,7 +127,6 @@ def test_create_job_pilot_returns_job_id() -> None: app.dependency_overrides.clear() -@requires_scrape_cadastre @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list() -> None: """POST /cadastre/jobs scope=manual_list с явным списком.""" @@ -174,7 +156,6 @@ def test_create_job_manual_list() -> None: app.dependency_overrides.clear() -@requires_scrape_cadastre @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list_empty_quarters_returns_400() -> None: """scope=manual_list без quarters → 400.""" @@ -294,22 +275,3 @@ def test_cancel_job_not_found_returns_404() -> None: assert response.status_code == 404 finally: app.dependency_overrides.clear() - - -@pytest.mark.skip( - reason=( - "X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — " - "Caddy basic_auth + RBAC middleware достаточны). Admin endpoint'ы больше не " - "несут verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode " - "RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без " - "реверта security-решения #437. Тест проверял удалённое поведение." - ) -) -def test_create_job_no_token_returns_401() -> None: - """Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437).""" - client = TestClient(app) - response = client.post( - "/api/v1/admin/cadastre/jobs", - json={"scope": "pilot"}, - ) - assert response.status_code in (401, 503) diff --git a/backend/tests/api/v1/test_admin_ekburg_permits.py b/backend/tests/api/v1/test_admin_ekburg_permits.py index 7c75f376..1c98bac3 100644 --- a/backend/tests/api/v1/test_admin_ekburg_permits.py +++ b/backend/tests/api/v1/test_admin_ekburg_permits.py @@ -73,19 +73,3 @@ def test_trigger_invalid_year_returns_422(bad_year: int) -> None: client = TestClient(app) response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS) assert response.status_code == 422, f"year={bad_year} должен возвращать 422" - - -@pytest.mark.skip( - reason=( - "X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — " - "Caddy basic_auth + RBAC middleware достаточны). trigger_ekburg_permits больше не " - "несёт verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode " - "RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без " - "реверта security-решения #437. Тест проверял удалённое поведение." - ) -) -def test_trigger_no_token_returns_401_or_503() -> None: - """Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437).""" - client = TestClient(app) - response = client.post(ENDPOINT, json={}) - assert response.status_code in (401, 503), response.text diff --git a/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py b/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py index 292f3653..662e76a7 100644 --- a/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py +++ b/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py @@ -202,9 +202,7 @@ class TestParseTable11: # Реальная зона должна быть в результате assert "Зона жилой застройки" in zone_names # Артефакты (zone_name пуст, не is_total) отброшены - non_total_no_zone = [ - r for r in result if not r["zone_name"] and not r.get("is_total") - ] + non_total_no_zone = [r for r in result if not r["zone_name"] and not r.get("is_total")] assert non_total_no_zone == [] # Итого-строка осталась assert any(r.get("is_total") for r in result) @@ -483,15 +481,23 @@ class TestParsePptTepMocked: # ── Тест на реальном PDF-образце (skip в CI) ───────────────────────────────── - -_REAL_PDF_PATH = Path( - "C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf" +# +# Путь repo-relative, а не абсолютный `C:/Users/user/source/repos/...`: с +# Windows-путём условие пропуска не выполнялось НИ НА ОДНОЙ машине кроме одного +# старого дев-бокса, т.е. эти 6 тестов были не «пропущены по среде», а мертвы. +# Образец не в git (untracked `.playwright-mcp/`), поэтому в CI пропуск остаётся — +# но теперь он снимается тем, что файл кладут в репо-каталог на любой ОС. +_REAL_PDF_PATH = ( + Path(__file__).resolve().parents[4] + / ".playwright-mcp" + / "ekb-docs" + / "ppt2018_22823_poyasnit_PPT.pdf" ) @pytest.mark.skipif( not _REAL_PDF_PATH.exists(), - reason="Реальный PDF-образец отсутствует (только для локального запуска)", + reason=f"Реальный PDF-образец отсутствует ({_REAL_PDF_PATH}) — только локальный запуск", ) class TestParsePptTepRealPdf: """Интеграционные тесты на реальном образце ppt2018_22823.""" diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py index da53c58d..13316e56 100644 --- a/backend/tests/test_admin_weight_profiles.py +++ b/backend/tests/test_admin_weight_profiles.py @@ -399,41 +399,3 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N assert len(captured_params) == 1 assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID assert captured_params[0]["user_id"] == "user-test" - - -# ── Auth ─────────────────────────────────────────────────────────────────────── - - -_TOKEN_REMOVED_REASON = ( - "App-level X-Admin-Token header удалён 2026-05-23 (см. docstring " - "app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, " - "двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. " - "В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта " - "security-решения. Тест проверял удалённое поведение." -) - - -@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) -def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None: - """Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) - client = TestClient(app) - r = client.get( - "/api/v1/admin/site-finder/weight-profiles", - params={"user_id": "user-1"}, - # без headers — нет X-Admin-Token - ) - assert r.status_code == 401 - - -@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) -def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None: - """Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) - client = TestClient(app) - r = client.get( - "/api/v1/admin/site-finder/weight-profiles", - params={"user_id": "user-1"}, - headers={"X-Admin-Token": "wrong-token"}, - ) - assert r.status_code == 401 diff --git a/tradein-mvp/backend/tests/test_gar_flats_loader.py b/tradein-mvp/backend/tests/test_gar_flats_loader.py index e98fc4c5..62b2e9a3 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -449,8 +449,14 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: db = _live_session() assert db is not None try: - db.execute(text(_NORMALIZER_FN)) - db.execute(text(_CANON_FN)) + # exec_driver_sql, НЕ text(): в обеих функциях есть regexp-группы `(?:...)`, + # а `text()` читает `:Россия` / `:кв` / `:асть` как bind-параметры и падает + # с InvalidRequestError ещё до первого утверждения. Драйверный путь ничего + # не парсит. (Тест жил с 2026-06-28 и до #2741 не исполнялся ни разу — под + # skipif «нет БД» падение было не видно.) + raw = db.connection() + raw.exec_driver_sql(_NORMALIZER_FN) + raw.exec_driver_sql(_CANON_FN) db.execute( text( "CREATE TEMP TABLE gar_house_flats (" From 4ee4d4b8e2bd0757d45ac5786b7386c9947768c4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 19:59:34 +0300 Subject: [PATCH 068/130] =?UTF-8?q?fix(tradein/privacy):=20=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=D0=B0=20=D0=BA=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D1=87=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D1=83=20=D0=A0=D0=A4-=D0=B2=D0=B8=D0=B4=D1=83=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20erasure=20(#2547)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up к прошлому фиксу (regexp_replace \D): чистое удаление форматирования не закрывало разрыв, который сам ревьюер привёл в примере -- "+7 999 123-45-67" и "89991234567" после digit-stripping дают РАЗНЫЕ строки (79991234567 vs 89991234567, différent на первой цифре) -- классическая для РФ путаница 8/+7 trunk-префикса. _ru_phone_norm_sql(expr) добавляет второй шаг: если после digit-stripping получилось РОВНО 11 цифр с ведущей '8' -- заменить её на '7'. Точное тождество для российской нумерации, не эвристика (обсуждали: усечение до "последних 10 цифр" риск-скориальнее -- склеивает номера разных стран, удаление чужих данных хуже неудаления своих). Оба вызова (_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL) строят SQL-структуру из статичных фрагментов (имя колонки / CAST(:phone AS text)) -- ни один телефон не попадает в текст запроса напрямую. Живая проверка (throwaway Postgres 16 в docker): лид "89991234567" находится и удаляется по запросу "+7 999 123-45-67" -- ровно кейс из ревью. Встроенный counterfactual в самом тесте доказывает, что чистый digit-strip (прошлая версия фикса) для этой пары находит 0 строк. Negative control: номер, отличающийся одной значащей цифрой, НЕ удаляется (защита от ложного совпадения = удаления чужих данных). --- .../backend/app/services/data_erasure.py | 75 +++++++-- .../backend/tests/test_data_erasure.py | 146 ++++++++++++++++-- 2 files changed, 195 insertions(+), 26 deletions(-) diff --git a/tradein-mvp/backend/app/services/data_erasure.py b/tradein-mvp/backend/app/services/data_erasure.py index 28f2b098..05a84bba 100644 --- a/tradein-mvp/backend/app/services/data_erasure.py +++ b/tradein-mvp/backend/app/services/data_erasure.py @@ -16,11 +16,13 @@ WHO CAN BE IDENTIFIED, HONESTLY: * `estimate_ids` -- if they still have the link/PDF from their estimate (the UUID in the URL/QR-code IS their proof of "this is mine"). * `phone` -- if they left a contact-request lead with that phone. - Matched by NORMALIZED DIGITS ONLY (regexp_replace strips everything - but 0-9 on both sides), not an exact string: lead.py stores + Matched by CANONICAL RU DIGITS on both sides (see + `_ru_phone_norm_sql` below), not an exact string: lead.py stores `payload.phone` exactly as typed (no E.164 normalization, by - design), so the caller's "+7 999 123-45-67" must still find a row - saved as "89991234567" or any other formatting of the same digits. + design), so "+7 999 123-45-67", "8 (999) 123-45-67" and + "89991234567" must all find the same row. Covers ONLY the + RU 8-vs-7 trunk-prefix case (exact digit-count identity, no + heuristic truncation) -- see the helper's docstring for why. * `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own Telegram chat id -- not guessable/spoofable by a third party the way a name or IP would be). @@ -68,6 +70,40 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) +def _ru_phone_norm_sql(expr: str) -> str: + """SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр, + ведущая '7'), для сравнения "разного форматирования одного и того же номера" + (deep-review 2026-08-06, MEDIUM + follow-up). + + Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с + ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской + нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина + проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально + НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений + неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми + цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем + неудаление своих. Номера другой длины/страны просто не совпадут ни на + этом шаге, ни дальше -- безопасный отказ, не false positive. + + `expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или + `CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит + структуру запроса из литералов, вызывающих её мест ровно два (см. + _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон + не попадает в текст SQL напрямую, только через bind-параметр `:phone`. + """ + stripped = f"regexp_replace({expr}, '\\D', '', 'g')" + return ( + f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' " + f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)" + ) + + +# Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data +# (колонка trade_in_leads.phone / входной CAST(:phone AS text)). +_PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone") +_PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)") + + def erase_person_data( db: Session, *, @@ -125,30 +161,35 @@ def erase_person_data( # 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по # телефону (лид мог быть оставлен без attach к оценке вовсе). # - # ⚠️ Телефон сравнивается по НОРМАЛИЗОВАННЫМ цифрам, не литералом - # (deep-review 2026-08-06, MEDIUM). app/api/v1/lead.py сохраняет - # payload.phone КАК ПРИСЛАЛИ (намеренно -- полная E.164-нормализация - # вне scope MVP, см. lead.py::_PHONE_PATTERN), т.е. одна и та же - # строка может лежать в БД как "+7 999 123-45-67" ИЛИ "89991234567" - # ИЛИ любой другой форматировкой той же маски. Точное `phone = :phone` + # ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом + # (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py + # сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная + # E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN), + # т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67" + # ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone` # находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ # формат, каким когда-то ввёл номер -- почти никогда так. Раньше это # молча удаляло 0 строк и всё равно возвращало 200 "данные удалены": # для 152-ФЗ ложное подтверждение удаления хуже честной ошибки. - # `regexp_replace(x, '\\D', '', 'g')` с ОБЕИХ сторон сравнения снимает - # форматирование (пробелы/скобки/дефисы/+) и сравнивает голые цифры. - # Параметр -- CAST(:phone AS text), НЕ конкатенация (psycopg v3 / SQL - # injection convention, .claude/rules/backend.md). + # _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql + # выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую + # '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс, + # без усечения до "последних 10 цифр" (риск ложного совпадения с + # номером другой страны -- см. докстринг helper'а). Номера иных + # форматов/длин сравниваются как есть (просто не совпадут). Параметр -- + # CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL + # injection convention, .claude/rules/backend.md); сам SQL-текст + # собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет + # ни одного значения параметра. ids_param = [str(i) for i in all_estimate_ids] result = db.execute( text( - """ + f""" DELETE FROM trade_in_leads WHERE estimate_id = ANY(CAST(:ids AS uuid[])) OR ( CAST(:phone AS text) IS NOT NULL - AND regexp_replace(phone, '\\D', '', 'g') - = regexp_replace(CAST(:phone AS text), '\\D', '', 'g') + AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL} ) """ ), diff --git a/tradein-mvp/backend/tests/test_data_erasure.py b/tradein-mvp/backend/tests/test_data_erasure.py index 9ca1471d..9d0e039c 100644 --- a/tradein-mvp/backend/tests/test_data_erasure.py +++ b/tradein-mvp/backend/tests/test_data_erasure.py @@ -139,25 +139,51 @@ def test_erase_by_phone_only_touches_only_leads() -> None: def test_phone_delete_normalizes_digits_on_both_sides() -> None: - """Regression guard for the deep-review MEDIUM finding (2026-08-06): - lead.py stores phone exactly as typed (no E.164 normalization, by - design), so a differently-formatted-but-same-number erasure request - ('+7 999 123-45-67' vs a stored '89991234567') must still match. The old - exact `phone = :phone` comparison silently deleted 0 rows and still + """Regression guard for the deep-review MEDIUM finding (2026-08-06) + + follow-up (RU 8-vs-7 trunk prefix): lead.py stores phone exactly as typed + (no E.164 normalization, by design), so a differently-formatted-but- + same-number erasure request must still match, AND the RU '8...' vs + '+7...' trunk-prefix pair must collapse to the same canonical value. The + old exact `phone = :phone` comparison silently deleted 0 rows and still returned HTTP 200 'erased' -- worse than an honest error under 152-ФЗ. - Both sides of the comparison must go through regexp_replace, and the - literal-equality path must be gone.""" + Both sides must go through the SAME normalization (_PHONE_COLUMN_NORM_SQL + / _PHONE_PARAM_NORM_SQL, see _ru_phone_norm_sql), and the literal-equality + path must be gone.""" db = MagicMock() db.execute.side_effect = [_Result(rowcount=1)] data_erasure.erase_person_data(db, phone="+7 999 123-45-67") sql = _sql_of(db.execute.call_args_list[0]) - assert sql.count("regexp_replace") == 2 - assert "phone = :phone" not in sql + # The comparison uses EXACTLY the two module-level normalized fragments + # (not a hand-rolled inline duplicate) -- pins that both sides go through + # the SAME normalization function, not two independently-drifting copies. + col_norm = data_erasure._PHONE_COLUMN_NORM_SQL + param_norm = data_erasure._PHONE_PARAM_NORM_SQL + assert f"{col_norm} = {param_norm}" in sql + # RU trunk-prefix collapse present on BOTH sides (11 digits, leading '8' -> '7'). + assert sql.count("length(regexp_replace") == 2 + assert sql.count("= '8'") == 2 + assert sql.count("'7' ||") == 2 + assert "phone = :phone" not in sql # old literal-equality path must be GONE assert not re.search(r":\w+::", sql) # psycopg v3 CAST trap +def test_ru_phone_norm_sql_only_ever_takes_static_expressions() -> None: + """`_ru_phone_norm_sql` is a query-STRUCTURE builder, not a data path -- + the two module-level constants are the ONLY call sites, and both pass a + column name / CAST(:bind AS type), never an actual phone value. This + pins that contract so a future call site can't accidentally splice a + real phone string into the SQL text.""" + assert data_erasure._PHONE_COLUMN_NORM_SQL == data_erasure._ru_phone_norm_sql("phone") + assert data_erasure._PHONE_PARAM_NORM_SQL == data_erasure._ru_phone_norm_sql( + "CAST(:phone AS text)" + ) + # column side references the column, never the bind param; param side is the reverse. + assert ":phone" not in data_erasure._PHONE_COLUMN_NORM_SQL + assert "CAST(:phone AS text)" in data_erasure._PHONE_PARAM_NORM_SQL + + def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None: """Anonymous person with NO username, NO estimate link, NO lead phone -- but they DID message @MERAsupport_bot -- can still be identified by their own @@ -248,3 +274,105 @@ def test_real_erase_by_phone_finds_differently_formatted_number() -> None: ) db.commit() db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_finds_ru_trunk_prefix_variant() -> None: + """End-to-end on a real DB: the coordinator's exact follow-up gap + (2026-08-06) -- a lead stored as '89991234567' (leading '8') must be + found and deleted when the erasure requester supplies '+7 999 123-45-67' + (leading '+7'). Pure digit-stripping does NOT close this: stripped, the + two are '89991234567' vs '79991234567' -- different at digit 1. Only the + explicit 11-digit '8'->'7' collapse in _ru_phone_norm_sql makes them + equal. Counterfactual proven manually against this same DB (raw SQL, + see PR discussion): WITHOUT the collapse, `regexp_replace` alone finds 0 + rows for this exact pair.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + # Counterfactual: plain digit-stripping (the PRE-follow-up fix) does NOT + # match this pair -- proves the 8-vs-7 gap was real, not a strawman. + digits_only_match = db.execute( + _t( + "SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid) " + "AND regexp_replace(phone, '\\D', '', 'g') " + "= regexp_replace(CAST(:phone AS text), '\\D', '', 'g')" + ), + {"id": str(lead_id), "phone": "+7 999 123-45-67"}, + ).scalar() + assert digits_only_match == 0, "digit-stripping alone must NOT match 8- vs 7-prefix" + + out = data_erasure.erase_person_data(db, phone="+7 999 123-45-67") + + assert out["trade_in_leads_deleted"] == 1 + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 0 + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_does_not_match_different_number() -> None: + """Negative control: a number differing in even ONE significant digit + must NOT be found -- proves the normalization is an exact-identity + check, not a fuzzy/truncated match that could delete a STRANGER's data. + Stored '89991234567' vs requested '+7 999 123-45-68' (last digit 7->8) + -- same length, same RU-looking shape, one digit off -- zero rows.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + out = data_erasure.erase_person_data(db, phone="+7 999 123-45-68") + + assert out["trade_in_leads_deleted"] == 0, "one differing digit must NOT match" + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 1, "row must survive an erasure request for a DIFFERENT number" + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() From eb98852ddf1f3eeb366f3bb42b71284475d09d80 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 17:52:56 +0000 Subject: [PATCH 069/130] =?UTF-8?q?ci:=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=8F=D0=B7=D0=B0=D0=BD=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=B5=D0=B1=D1=8F=20=E2=80=94=20=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D1=81=D0=BD=D1=8B=D0=B9=20(#2745)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/ci-tradein.yml | 112 ++++++++++++++++++- .forgejo/workflows/ci.yml | 77 +++++++++++-- backend/tests/conftest.py | 68 +++++++++++ backend/tests/skip_allowlist.txt | 89 +++++++++++++++ tradein-mvp/backend/tests/conftest.py | 68 +++++++++++ tradein-mvp/backend/tests/skip_allowlist.txt | 39 +++++++ 6 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 backend/tests/skip_allowlist.txt create mode 100644 tradein-mvp/backend/tests/skip_allowlist.txt diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index c3f6bcda..5b228267 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -66,16 +66,104 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' + # Postgres-сервис (#2745). ДО него лэйн был mock-only: DATABASE_URL указывал на + # заведомо мёртвый `localhost:5432/test`, и девять тестов с `_live_session()` + # self-skip'ались — в CI они не бежали НИ РАЗУ. Так и разъехался со схемой + # test_house_dedup_merge (#2740: houses.url стал NOT NULL), а + # test_gar_flats_loader вообще падал до первого утверждения (#2744). + # + # Замер перед включением: полный сьют на mock-лэйне 122с / 3858 passed / 10 skipped, + # тот же сьют против живой БД — 106с / 3867 passed / 1 skipped. Живая БД не + # медленнее, поэтому НЕ добавляем второй job, а чиним этот: один прогон, на + # девять реальных проверок больше. Накладные — только подъём контейнера и + # bootstrap схемы (219 файлов, ~20с). defaults: run: working-directory: ./tradein-mvp/backend env: - # psycopg v3 требует parseable URL на импорте; реального коннекта нет — - # DB-тесты мокаются (mirror deploy-tradein.yml test-job). - DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test + # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. + CI_PG: ci-pg-tradein-${{ github.run_id }} steps: - uses: actions/checkout@v4 + - name: Поднять Postgres и собрать схему tradein + working-directory: . + # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА. + # Раннер запускает и job, и сервис-контейнеры с `--network host` (видно в + # логе прогона: `docker create image=... network="host"`), а на 5432 того + # же хоста слушает ПРОДОВЫЙ Postgres. Попытка через `services:` + + # `ports: 5432:5432` кончилась тем, что сервис-контейнер не смог занять + # порт, а psql из job'а ушёл В ПРОД и получил + # `password authentication failed for user "tradein"`. То есть + # `localhost:5432` из job'а на этом раннере — боевая база, а не тестовая. + # Поэтому контейнер поднимаем сами, в bridge-сети, БЕЗ публикации порта, + # и ходим по его собственному IP: прод недостижим в принципе, параллельные + # прогоны не конфликтуют, psql берём из самого контейнера. + # + # ОДИН шаг, а не два: между шагами контейнер успевал исчезнуть, и + # bootstrap падал на `container is not running`. + # + # `pg_isready -h 127.0.0.1`, а НЕ через unix-сокет: на время initdb образ + # поднимает ВРЕМЕННЫЙ сервер с listen_addresses='' — по сокету он уже + # отвечает «accepting connections», хотя снаружи БД ещё не существует, а + # впереди рестарт. Проба по TCP зеленеет только на настоящем сервере — + # том самом, к которому пойдут тесты. + # + # postgis, не plain postgres: tests/tasks/test_cadastral_geo_match.py + # проверяет KNN по geometry (PostGIS_Version() в connectivity-probe). + # Имя БД ОБЯЗАНО отличаться от `test`: `_live_session()` считает DSN с + # `localhost:5432/test` заглушкой и вернул бы None — контейнер поднялся + # бы, а тесты всё равно скипались. + run: | + set -u + docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker run -d --name "$CI_PG" \ + -e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \ + postgis/postgis:16-3.4 + + ready="" + for _ in $(seq 1 45); do + if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U tradein -q 2>/dev/null; then + ready=1; break + fi + [ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break + sleep 2 + done + if [ -z "$ready" ]; then + echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)" + docker logs --tail 50 "$CI_PG" 2>&1 || true + exit 1 + fi + + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG") + [ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; } + echo "DATABASE_URL=postgresql+psycopg://tradein:tradein@${ip}:5432/tradein" >> "$GITHUB_ENV" + echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" + + # Тот же порядок и тот же строгий режим, что в deploy-tradein.yml: + # `ls | sort` + ON_ERROR_STOP=on, падение любой миграции → job RED. + # Никаких «применилось как получилось»: схема в CI либо та же, что на + # проде, либо гейта нет. + docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q -c \ + "CREATE EXTENSION IF NOT EXISTS postgis; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE ROLE gendesign_reader;" + for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do + fname=$(basename "$sql_file") + # ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а + # backfill, читающий foreign table gendesign_rosreestr_deals из БД + # ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING + # создать не из чего. На пустых таблицах backfill всё равно no-op. + if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then + echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет" + continue + fi + docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \ + || { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; } + done + echo "✓ схема собрана: $(docker exec "$CI_PG" psql -U tradein -d tradein -tAc \ + "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'") таблиц" + - name: Install uv # Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается # на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI). @@ -121,7 +209,19 @@ jobs: # NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого # deselect'а — он остаётся до #2680, который правит тот файл. Расхождение # безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный. - run: uv run pytest -q + # + # `-rs` (#2745) — КАЖДЫЙ пропуск печатает свою причину в лог job'а. Без него + # `-q` рисует пропуск точкой `s`, неотличимой на глаз от прогона: ровно так + # девять DB-тестов «шли зелёными», ничего не проверяя. Пропуск, который не + # называет себя вслух, со временем перестаёт быть верным. + run: uv run pytest -q -rs + + - name: Снести тестовый Postgres + # if: always() — контейнер уходит и когда сьют красный, и когда прогон + # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. + if: always() + working-directory: . + run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог @@ -169,7 +269,9 @@ jobs: - name: Run pytest (tradein-mvp/browser) # Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего # не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped. - run: pytest -q + # `-rs`: если однажды появится пропуск, он назовёт причину в логе, а не + # растворится в строке точек. + run: pytest -q -rs frontend-checks: runs-on: ubuntu-latest diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index a261bc30..66d5fd7f 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -69,6 +69,20 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' + # Postgres-сервис (#2745). Раньше DATABASE_URL указывал на заведомо мёртвый + # хост, и весь tests/sql/ (10 тестов: #17 velocity-alerts, #99 ДДУ-индикатор, + # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки + # не бежали ни разу с момента написания. + # + # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные + # таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему — + # проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не + # нужен, в отличие от tradein-лэйна. + # + # TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/ + # (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по + # SSH-туннелю. Пустой контейнер дал бы там красноту на пустом месте, поэтому + # integration остаётся честно пропущенным — с причиной в логе (`-rs`). defaults: run: working-directory: backend @@ -76,14 +90,53 @@ jobs: # TESTING=1 активирует RBAC-bypass (app/main.py rbac_guard пропускает # запросы при settings.testing=True) — иначе 401 на всём /api/v1. TESTING: "1" - # Stub DSN: psycopg v3 требует parseable URL на импорте; реального коннекта - # нет — DB-тесты мокаются, real-DB тест (tests/sql/) self-skip'ается через - # connectivity-probe к этому хосту (5432 недоступен → skip). - DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test REDIS_URL: redis://localhost:6379/0 + # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. + CI_PG: ci-pg-backend-${{ github.run_id }} steps: - uses: actions/checkout@v4 + - name: Поднять Postgres для тестов + working-directory: . + # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА — подробный разбор в + # ci-tradein.yml (тот же раннер). Кратко: job и сервис-контейнеры идут с + # `--network host`, а на 5432 этого хоста слушает ПРОДОВЫЙ Postgres, то + # есть `localhost:5432` из job'а — боевая база. Поднимаем контейнер сами, + # в bridge-сети, без публикации порта, ходим по его IP. + # + # `pg_isready -h 127.0.0.1`, а не через unix-сокет: по сокету отвечает + # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД + # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. + # + # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные + # таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы + # здесь не нужен вовсе, в отличие от tradein-лэйна. + run: | + set -u + docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker run -d --name "$CI_PG" \ + -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ + postgres:16 + + ready="" + for _ in $(seq 1 45); do + if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U gendesign -q 2>/dev/null; then + ready=1; break + fi + [ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break + sleep 2 + done + if [ -z "$ready" ]; then + echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)" + docker logs --tail 50 "$CI_PG" 2>&1 || true + exit 1 + fi + + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG") + [ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; } + echo "DATABASE_URL=postgresql+psycopg://gendesign:gendesign@${ip}:5432/gendesign_ci" >> "$GITHUB_ENV" + echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" + - name: Set up Python uses: actions/setup-python@v5 with: @@ -136,10 +189,13 @@ jobs: # но --ignore — belt-and-suspenders на случай сбора фикстур). # tests/integration self-skip'ается через requires_test_db (skipif на # TEST_DATABASE_URL, который тут не задан) → НЕ игнорим, оно чисто skip'ается. - # tests/sql/ mv_layout self-skip'ается через Postgres-connectivity probe - # (5432 недоступен в этом mock-lane) → SKIP. Это intended. + # tests/sql/ теперь РЕАЛЬНО ИДУТ — postgres-контейнер выше (#2745). # PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK. # + # `-rs` (#2745): каждый оставшийся пропуск печатает причину. Под `-q` без + # него пропуск неотличим от прогона — именно так проверка тихо перестаёт + # исполняться и об этом узнают, когда на неё надо опереться (#2722/#2729/#2740). + # # Coverage-gate (#68): --cov=app меряет покрытие пакета app/. # --cov-fail-under=65 → job RED если покрытие упало ниже baseline # (измерено 2026-06: mock-lane сьют ~71%, см. [tool.coverage] в pyproject; @@ -148,7 +204,7 @@ jobs: # coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge). # term-missing → видно непокрытые строки прямо в job-логе. run: | - uv run pytest -q --ignore=tests/smoke \ + uv run pytest -q -rs --ignore=tests/smoke \ --cov=app \ --cov-report=term-missing:skip-covered \ --cov-report=xml:coverage.xml \ @@ -169,6 +225,13 @@ jobs: echo "$report" fi + - name: Снести тестовый Postgres + # if: always() — контейнер уходит и когда сьют красный, и когда прогон + # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. + if: always() + working-directory: . + run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + frontend-tests: runs-on: ubuntu-latest needs: changes diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4a5fb6f2..a3266cb6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок ` from __future__ import annotations import os +from pathlib import Path import pytest @@ -40,3 +41,70 @@ def _clear_dependency_overrides(): app.dependency_overrides.clear() except Exception: pass + + +# ── Бюджет пропусков (#2745) ────────────────────────────────────────────────── +# +# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта: +# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и +# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI +# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть +# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в +# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал +# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно. +# +# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и +# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не +# ошибка; ошибка — пропуск без записи. +# +# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без +# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить. +# +# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона, +# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких +# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml). + +_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt" +_observed_skips: set[str] = set() + + +def _allowed_skips() -> set[str]: + if not _SKIP_ALLOWLIST_PATH.exists(): + return set() + lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines() + return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())} + + +def _record_skip(report) -> None: + if report.skipped: + # nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может + # отсутствовать, а не каждую её параметризацию. + _observed_skips.add(report.nodeid.split("[", 1)[0]) + + +def pytest_runtest_logreport(report) -> None: + _record_skip(report) + + +def pytest_collectreport(report) -> None: + # Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в + # logreport, и nodeid у него — путь файла. + _record_skip(report) + + +def pytest_sessionfinish(session, exitstatus) -> None: + unlisted = sorted(_observed_skips - _allowed_skips()) + if not unlisted: + return + print( + f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" + ) + for nodeid in unlisted: + print(f" - {nodeid}") + print( + "Почини тест либо внеси его в skip_allowlist.txt с причиной — " + "пропуск без записи неотличим от пройденной проверки." + ) + if exitstatus == 0: + session.exitstatus = 1 diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt new file mode 100644 index 00000000..6fda8d13 --- /dev/null +++ b/backend/tests/skip_allowlist.txt @@ -0,0 +1,89 @@ +# Объявленные пропуски сьюта backend/. +# +# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не +# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала +# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда, +# когда на неё понадобилось опереться (#2722, #2729, #2740). +# +# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий. +# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ +# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра. +# +# Список — НАДмножество сред: на CI (ubuntu + libpango + postgres-сервис) часть +# записей не срабатывает, на ноутбуке без них — срабатывает. Запись, чей пропуск +# не случился, безвредна; пропуск без записи — нет. + +# ── WeasyPrint: нужны native-либы (Pango/cairo/GObject) ─────────────────────── +# В CI ЭТИ ТЕСТЫ ИДУТ: ci.yml ставит libcairo2/libpango-1.0-0/libpangoft2-1.0-0. +# Записи нужны только для macOS-dev, где libgobject-2.0-0 нет. +tests/api/v1/test_parcels_forecast.py::test_export_forecast_pdf_returns_pdf +tests/services/exporters/test_report_pdf.py::TestConfidenceSectionParity::test_dict_shaped_factor_produces_valid_pdf +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_accepts_as_dict_input +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_non_trivial_length +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_pdf_magic_bytes +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_empty_report_still_valid_pdf +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_garbage_input_does_not_crash +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_partial_report_some_sections +tests/services/generative/test_exporters.py::test_pdf_export_produces_pdf_bytes +tests/test_layout_tz_pdf.py + +# ── phantom-column gate: нужна КОПИЯ ПРОДОВОЙ схемы ─────────────────────────── +# Смысл этих тестов — поймать колонку, которой нет в реальной БД, поэтому пустой +# сервис-контейнер их не заменяет: против пустой схемы они дали бы красноту, не +# относящуюся к делу. Условие: TEST_DATABASE_URL на SSH-туннель к проду +# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py. +# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**. +tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap +tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary +tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query +tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query +tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_supply_batch_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_full_cte_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_radius_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_velocity_competitor_query_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rns_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rve_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_recent_permits_query +tests/integration/test_phantom_columns.py::TestMvLayoutVelocity::test_velocity_by_room_bucket +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_corpus_room_month_columns +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_mapping_columns +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_sold_count_query + +# ── SQL-логика на живой Postgres ────────────────────────────────────────────── +# В CI ЭТИ ТЕСТЫ ИДУТ с #2745 (postgres-сервис в ci.yml); до него не бежали ни +# разу с момента написания. Схема не нужна — строят себе временные таблицы. +# Локально: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:16 +tests/sql/test_ddu_price_indicator.py::test_basis_and_previous_index +tests/sql/test_ddu_price_indicator.py::test_packaged_deal_bucketed_by_per_unit_area +tests/sql/test_ddu_price_indicator.py::test_prev_period_value_skips_filtered_quarter +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_hand_computed_weighted_average +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_no_zero_months_weighted_equals_naive +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_nullif_prevents_division_by_zero +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_weighted_differs_from_naive_sparse_project +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_zero_deal_months_skew_naive_avg +tests/sql/test_velocity_alerts.py::test_lookback_anchors_to_latest_data_month +tests/sql/test_velocity_alerts.py::test_sharp_drop_is_flagged + +# ── Живая сеть NSPD (nspd.gov.ru) ───────────────────────────────────────────── +# Ходят в внешний сервис: в гейте это флейк и зависимость от чужого аптайма. +# Запуск вручную: uv run pytest tests/scrapers/ -m slow / -m integration -s +tests/scrapers/test_nspd_bulk_client.py::test_list_objects_in_building_real +tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_empty +tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb +tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real +tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped + +# ── Образцы, которых нет в git ──────────────────────────────────────────────── +# ДОЛГ, а не норма: парсеры проверяются на реальных дампах, которые лежат только +# в untracked `.playwright-mcp/`. Пока файлы не в репозитории, эти 8 проверок не +# исполняются нигде. Правильная починка — закоммитить образцы в tests/fixtures/ +# (как сделано в tradein-mvp/backend/tests/fixtures/*.html), после чего строки +# отсюда убрать. +tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_no_duplicate_site_names +tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_site_count_from_dump +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_tep_has_rows +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_itogo +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_rows diff --git a/tradein-mvp/backend/tests/conftest.py b/tradein-mvp/backend/tests/conftest.py index f9d02c57..9651c161 100644 --- a/tradein-mvp/backend/tests/conftest.py +++ b/tradein-mvp/backend/tests/conftest.py @@ -9,6 +9,7 @@ from __future__ import annotations import sys +from pathlib import Path import pytest @@ -57,3 +58,70 @@ def _no_leaked_password_verify_slots(): f"тест оставил {inflight} занятых слотов проверки пароля (по ключам: {by_key}) — " "утечка слота при пуле в 1 поток это вечный 429 всем на входе" ) + + +# ── Бюджет пропусков (#2745) ────────────────────────────────────────────────── +# +# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта: +# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и +# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI +# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть +# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в +# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал +# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно. +# +# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и +# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не +# ошибка; ошибка — пропуск без записи. +# +# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без +# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить. +# +# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона, +# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких +# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml). + +_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt" +_observed_skips: set[str] = set() + + +def _allowed_skips() -> set[str]: + if not _SKIP_ALLOWLIST_PATH.exists(): + return set() + lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines() + return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())} + + +def _record_skip(report) -> None: + if report.skipped: + # nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может + # отсутствовать, а не каждую её параметризацию. + _observed_skips.add(report.nodeid.split("[", 1)[0]) + + +def pytest_runtest_logreport(report) -> None: + _record_skip(report) + + +def pytest_collectreport(report) -> None: + # Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в + # logreport, и nodeid у него — путь файла. + _record_skip(report) + + +def pytest_sessionfinish(session, exitstatus) -> None: + unlisted = sorted(_observed_skips - _allowed_skips()) + if not unlisted: + return + print( + f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" + ) + for nodeid in unlisted: + print(f" - {nodeid}") + print( + "Почини тест либо внеси его в skip_allowlist.txt с причиной — " + "пропуск без записи неотличим от пройденной проверки." + ) + if exitstatus == 0: + session.exitstatus = 1 diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt new file mode 100644 index 00000000..5c0b9a20 --- /dev/null +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -0,0 +1,39 @@ +# Объявленные пропуски сьюта tradein-mvp/backend. +# +# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не +# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала +# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда, +# когда на неё понадобилось опереться (#2722, #2729, #2740). +# +# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий. +# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ +# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра. +# +# Список — НАДмножество сред: в CI Postgres есть (ci-tradein.yml поднимает +# сервис-контейнер и собирает схему из backend/data/sql/), на ноутбуке без БД — +# нет. Запись, чей пропуск не случился, безвредна; пропуск без записи — нет. + +# ── Реальный рендер PDF: нужны native-либы WeasyPrint (Pango/cairo/GObject) ──── +# Где выполняется: внутри prod-образа — runner-stage tradein-mvp/backend/Dockerfile +# ставит libcairo2 + libpango-1.0-0 + libpangoft2-1.0-0: +# docker exec tradein-backend python -m pytest -q -m pdf_render tests/test_pdf_real_render.py +# На голом ubuntu-latest в ci-tradein.yml этих либ нет (apt-шага в job'е нет). +tests/test_pdf_real_render.py + +# ── Тесты против живой Postgres ─────────────────────────────────────────────── +# В CI они ИДУТ (postgres-сервис в ci-tradein.yml, #2745) — записи ниже нужны +# только для прогона на машине без БД. Именно эта группа разъехалась со схемой, +# пока не бежала нигде: #2740 (houses.url стал NOT NULL) и #2744 +# (test_gar_flats_loader падал до первого утверждения). Поднять локально: +# docker run -d -p 5432:5432 -e POSTGRES_DB=tradein -e POSTGRES_USER=tradein \ +# -e POSTGRES_PASSWORD=tradein postgis/postgis:16-3.4 +# psql ... -f backend/data/sql/*.sql # порядок как в deploy-tradein.yml +tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked +tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows +tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count +tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics +tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover +tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard +tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal +tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent +tests/test_user_events.py::test_real_record_event_inserts_row From 2496670859d1e04a464ec5e11393f72ef4cdef9a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 18:20:04 +0000 Subject: [PATCH 070/130] =?UTF-8?q?fix(tests):=20=D0=BE=D0=B1=D1=8A=D1=8F?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=204=20live-DB=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=B2=20skip=5Fallowlis?= =?UTF-8?q?t=20(#2750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/tests/skip_allowlist.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 5c0b9a20..02f7d075 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -37,3 +37,12 @@ tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent tests/test_user_events.py::test_real_record_event_inserts_row + +# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main +# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и +# поймал: 4 проверки не исполнились и не были объявлены. Проверено против живой +# БД (схема из backend/data/sql/ по 233 включительно) — 29 passed, тесты живы. +tests/test_data_erasure.py::test_real_erase_by_phone_does_not_match_different_number +tests/test_data_erasure.py::test_real_erase_by_phone_finds_differently_formatted_number +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 From 0535fa209a4af8ec6a3d96222b77f1d100388700 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 6 Aug 2026 18:27:39 +0000 Subject: [PATCH 071/130] =?UTF-8?q?chore(tradein/db):=20=D1=83=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86,=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B5=D0=B9=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20=D0=B8=20=D0=B7=D0=B2=D1=91=D0=B7=D0=B4?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B2=20v=5Fdata=5Fquality=20(#2746?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграции 222 (DROP 2 tmp-таблиц + 5 строгих дублей индексов + v_data_quality с явным списком колонок) и 225 (CREATE INDEX CONCURRENTLY под FK listing_source_snapshots.run_id). Проверено на прод-БД в BEGIN…ROLLBACK и на чистой схеме (полный bootstrap 225 миграций в одноразовом контейнере). --- .../backend/data/sql/222_db_audit_cleanup.sql | 140 ++++++++++++++++++ ...25_listing_source_snapshots_run_id_idx.sql | 42 ++++++ .../backend/data/sql/_manifest_applied.txt | 9 ++ 3 files changed, 191 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql create mode 100644 tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql diff --git a/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql new file mode 100644 index 00000000..35bfb069 --- /dev/null +++ b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql @@ -0,0 +1,140 @@ +-- 222_db_audit_cleanup.sql +-- Уборка по итогам ручного аудита схемы tradein (2026-08-06). Три независимых +-- части, порядок между ними не важен (разные объекты, нет пересекающихся +-- зависимостей). Идемпотентно целиком: IF EXISTS везде, CREATE OR REPLACE VIEW. +-- +-- ── A. Временные таблицы разовой чистки 02.07 ───────────────────────────────── +-- tmp_purged_junk_houses_0702 / tmp_purged_junk_links_0702 — снэпшоты записей, +-- вычищенных вручную 2026-07-02. Проверено на проде перед этой миграцией: +-- * обе существуют под этими именами, суммарно 2.9 МБ +-- (tmp_purged_junk_houses_0702 = 1608 kB, tmp_purged_junk_links_0702 = 1272 kB); +-- * pg_constraint: ни один FK НЕ ссылается на них (confrelid пусто); +-- * pg_depend: ни view, ни другой объект их не использует (только сами по себе). +-- Дальше не нужны — были just-in-case снэпшотом на случай отката чистки, месяц +-- прошёл без претензий. +-- +-- ── B. Пять строгих дублей индексов ─────────────────────────────────────────── +-- Строгий дубль = тот же access method + тот же УПОРЯДОЧЕННЫЙ список колонок +-- (включая ASC/DESC/NULLS) + тот же частичный предикат (или оба NULL) + тот же +-- opclass, независимо от UNIQUE-флага и имени. Проверено запросом по +-- pg_index/pg_stat_user_indexes/pg_opclass на проде — найдено РОВНО 5 пар (не 6, +-- см. примечание ниже), в каждой паре оставляем индекс, несущий UNIQUE-constraint +-- (дропнуть его нельзя без дропа constraint'а), дропаем чистый btree-дубль: +-- +-- agents: DROP agents_source_ext_idx +-- (дубль agents_ext_source_ext_agent_id_key, btree (ext_source, ext_agent_id); +-- 2 скана за всё время — планировщик и так предпочитал unique-версию) +-- ekb_geoportal_buildings: DROP ix_ekb_geoportal_buildings_street_house +-- (дубль ekb_geoportal_buildings_street_norm_house_norm_key, +-- btree (street_norm, house_norm); 13 945 сканов, но unique-версия того же +-- определения покрывает те же запросы — 70 702 скана на ней) +-- house_placement_history: DROP hph_source_item_idx +-- (дубль house_placement_history_source_ext_item_id_key, +-- btree (source, ext_item_id); 0 сканов — полностью мёртв) +-- house_reviews: DROP hr_source_ext_idx +-- (дубль house_reviews_source_ext_review_id_key, btree (source, ext_review_id); +-- 15 сканов) +-- sellers: DROP sellers_source_idx +-- (дубль sellers_source_ext_seller_id_key, btree (source, ext_seller_id); +-- 2 скана) +-- +-- ПРИМЕЧАНИЕ (расхождение с ожиданием «шесть»): при систематической проверке +-- (3 независимых метода: нормализованный DDL-текст, сравнение indkey/indoption, +-- сравнение opclass) строгих дублей найдено 5, не 6. Два похожих кандидата +-- ЦЕЛЕНАПРАВЛЕННО исключены — их «дубль» только по списку колонок, а порядок +-- сортировки различается (ASC,ASC у unique-версии против ASC,DESC у второй), +-- то есть это ТОТ ЖЕ класс исключения, что explicitly подтверждённые +-- idx_lss_source_date/listings_snapshots_listing_date_idx (#2607, см. миграцию 225): +-- * offer_price_history: oph_listing_time_idx (listing_id ASC, change_time DESC) +-- против offer_price_history_listing_change_uq (listing_id ASC, change_time ASC) +-- * houses_price_dynamics: hpd_house_dim_idx (..., month_date DESC) +-- против houses_price_dynamics_dim_key (..., month_date ASC) +-- Оба НЕ тронуты. Если «шесть» подразумевали один из них — нужно явное +-- подтверждение, что смешанный порядок сортировки в конкретном запросе не +-- используется (тем же способом, каким для idx_lss_source_date подтверждено +-- обратное). +-- +-- ── C. v_data_quality — явный список колонок вместо SELECT * ───────────────── +-- Сейчас: `WITH active_listings AS (SELECT * FROM listings WHERE is_active = true)`. +-- Postgres разворачивает `*` в CREATE VIEW time в полный список колонок listings +-- (89 на момент миграции) и фиксирует pg_depend на КАЖДУЮ из них — это то самое +-- уже задокументированное в 214/216 предупреждение («View-зависимость: v_data_quality +-- содержит SELECT * FROM listings, что фиксирует column-level зависимость на ВСЕ +-- колонки»), которое обязывало делать DROP VIEW → DROP COLUMN → CREATE VIEW при +-- каждой чистке listings. +-- Фактически используются только 6 колонок active_listings ниже по телу view: +-- id — IN (SELECT active_listings.id FROM active_listings) +-- lat — pct_geocoded +-- cadastral_number — pct_cadastr +-- description — pct_description +-- house_id_fk — JOIN houses h ON h.id = l.house_id_fk (pct_year_built) +-- is_active — WHERE-фильтр самого CTE (создаёт зависимость даже не будучи +-- в SELECT-списке, поэтому перечислен явно для наглядности) +-- Поведение view НЕ меняется — только явный список вместо *. Тело SELECT (16 +-- выходных колонок) скопировано без изменений из 216_dead_code_sweep.sql. +-- COMMENT ON VIEW сохраняется автоматически (CREATE OR REPLACE VIEW не сбрасывает +-- комментарий). +-- +-- Dependencies: 002_core_tables.sql (listings), 214_drop_dead_run_metrics.sql, +-- 216_dead_code_sweep.sql (последний DDL v_data_quality). +-- Идемпотентно: DROP TABLE IF EXISTS / DROP INDEX IF EXISTS / CREATE OR REPLACE VIEW. + +BEGIN; + +-- ── A ────────────────────────────────────────────────────────────────────── +DROP TABLE IF EXISTS tmp_purged_junk_houses_0702, tmp_purged_junk_links_0702; + +-- ── B ────────────────────────────────────────────────────────────────────── +DROP INDEX IF EXISTS agents_source_ext_idx; +DROP INDEX IF EXISTS ix_ekb_geoportal_buildings_street_house; +DROP INDEX IF EXISTS hph_source_item_idx; +DROP INDEX IF EXISTS hr_source_ext_idx; +DROP INDEX IF EXISTS sellers_source_idx; + +-- ── C ────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT id, lat, cadastral_number, description, house_id_fk, is_active + FROM listings + WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql new file mode 100644 index 00000000..e783502e --- /dev/null +++ b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql @@ -0,0 +1,42 @@ +-- 225_listing_source_snapshots_run_id_idx.sql +-- Индекс под внешний ключ listing_source_snapshots.run_id → scrape_runs(id) +-- ON DELETE SET NULL. Индекса на run_id нет (проверено \d listing_source_snapshots +-- на проде): есть только listing_source_snapshots_pkey (listing_source_id, +-- snapshot_date), idx_lss_source_date (listing_source_id, snapshot_date DESC), +-- idx_lss_snapshot_date (snapshot_date DESC) — ни один не начинается с run_id. +-- Таблица — 2 872 080 строк (reltuples), 696 MB (pg_total_relation_size). +-- Без индекса каждый DELETE из scrape_runs делает Seq Scan по 2.87М строк, чтобы +-- обнулить run_id у зависимых снэпшотов (ON DELETE SET NULL). +-- +-- ── Почему CONCURRENTLY и почему в файле нет BEGIN/COMMIT ──────────────────── +-- CREATE INDEX CONCURRENTLY не может выполняться внутри блока транзакции +-- (Postgres: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block"). +-- На 2.87М строк / 696 MB обычный CREATE INDEX держит ACCESS EXCLUSIVE lock на +-- время сборки (секунды-десятки секунд под нагрузкой) — на боевой таблице, +-- которую пишет активный скрейпинг, это неприемлемо; нужен CONCURRENTLY. +-- +-- Более ранние миграции с похожей потребностью (117, 120, 134, 137) сознательно +-- ОТКАЗАЛИСЬ от CONCURRENTLY с комментарием «deploy migration runner wraps each +-- file in an explicit transaction (BEGIN/COMMIT)». Перепроверено перед этой +-- миграцией: .forgejo/workflows/deploy-tradein.yml, шаг применения миграций +-- (`for sql_file in ...; psql -v ON_ERROR_STOP=on < "$sql_file"`) НЕ добавляет +-- собственный BEGIN/COMMIT и не передаёт `-1`/`--single-transaction` — транзакция +-- в тех файлах возникала ТОЛЬКО из-за их же собственных BEGIN;...COMMIT; внутри +-- файла, не из-за механизма деплоя. Эмпирическое подтверждение: в data/sql уже +-- есть применённые на проде миграции без BEGIN/COMMIT вовсе (003_seed_deals.sql, +-- 005_geocode_tracking.sql, 218_scrape_runs_ban_kind.sql, +-- 223_scrape_runs_time_columns_meaning.sql) — psql выполняет их операторы с +-- autocommit по одному, деплой не падает. Поэтому здесь BEGIN/COMMIT сознательно +-- ОПУЩЕН: файл — это один самостоятельный CREATE INDEX CONCURRENTLY, выполняемый +-- psql в autocommit-режиме. +-- +-- Идемпотентно: IF NOT EXISTS. (Единственный неидемпотентный случай — если +-- предыдущая попытка CONCURRENTLY была прервана и оставила INVALID индекс с тем +-- же именем; тогда IF NOT EXISTS молча НЕ пересоздаст его валидным, и потребуется +-- ручной `DROP INDEX CONCURRENTLY idx_lss_run_id;` перед повтором — это штатное +-- поведение CONCURRENTLY, не специфика этого файла.) +-- Dependencies: 079_listing_source_history.sql (создала таблицу и оба FK). +-- Deploy order: standalone, независим от 222_db_audit_cleanup.sql. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lss_run_id + ON listing_source_snapshots (run_id); diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d71c6d7..52a76097 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -230,4 +230,13 @@ # Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. +# +# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент +# правки они уже слиты в main и применены на проде (см. _schema_migrations), +# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш; +# self-maintenance-контракт (см. докстринг test_migrations_manifest.py) +# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для +# 222/225 по прецеденту 233_payments.sql. +222_db_audit_cleanup.sql +225_listing_source_snapshots_run_id_idx.sql 233_payments.sql From ac870b0c582a291d22a7233c588db876f748673a Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 6 Aug 2026 18:47:35 +0000 Subject: [PATCH 072/130] =?UTF-8?q?fix(ptica):=20=D1=81=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=20=D0=9F=D0=94=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=BE=D0=B9=20=D0=B2?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=20+=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20=D0=9D=D0=94=D0=A1=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=D1=85=20(#2457)?= =?UTF-8?q?=20(#2749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PII scrub wired to BOTH channels (before_send AND before_send_transaction) in app/main.py and app/workers/celery_app.py. Before: Celery had no before_send at all, and before_send_transaction was URL-only while glitchtip_traces_sample_rate defaults to 0.05 - the Starlette integration puts request.data on transaction scope exactly as on error scope, so lead bodies leaked through the transaction channel. Keys: full MERA set (client_name/client_phone/client_email/phone/email/name) plus company/message from PilotRequestInput. VAT label: 'NDS (parking)' -> 'NDS (parking + commercial)' in DOCX/HTML exporters - financial.py computes VAT over parking AND non-residential. --- backend/app/main.py | 11 +- backend/app/observability/sentry_scrub.py | 107 ++++++- .../services/exporters/full_report_docx.py | 2 +- .../services/exporters/full_report_html.py | 2 +- backend/app/workers/celery_app.py | 10 +- backend/tests/test_sentry_init.py | 261 +++++++++++++++++- 6 files changed, 381 insertions(+), 12 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index ee3969ec..7162bfa0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,7 +48,7 @@ from app.core import auth_db from app.core.audit_middleware import audit_log_middleware from app.core.auth import get_role from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event from app.services.auth_session import resolve_session_token logger = logging.getLogger(__name__) @@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers): # (middleware, маршруты) видели активный client с самого старта процесса. # GlitchTip не поддерживает profiling — profiles_sample_rate=0.0. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review): + # Starlette-интеграция кладёт request.data на transaction-scope так же, как + # на error-scope, поэтому голый scrub_sensitive_query (только URL) на + # before_send_transaction оставлял бы PII-канал открытым при любом + # glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring). sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -82,8 +87,8 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send=scrub_sensitive_query, - before_send_transaction=scrub_sensitive_query, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ StarletteIntegration(), FastApiIntegration(), diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py index af936c31..d40911ae 100644 --- a/backend/app/observability/sentry_scrub.py +++ b/backend/app/observability/sentry_scrub.py @@ -1,17 +1,63 @@ -"""Хук before_send_transaction для GlitchTip/Sentry SDK. +"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK. -Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы -секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip -через HttpxIntegration performance-spans. +`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед +отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в +GlitchTip через HttpxIntegration performance-spans. + +`scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone / +client_email / phone / email / name / company / message) из events перед +отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на +sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в +request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в +`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/ +`message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный +вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет +сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/ +sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/ +client_email — Птица их не использует сегодня, но одинаковый механизм на +оба продукта проще сопровождать), плюс `company`/`message`, специфичные для +`PilotRequestInput` (#2457-review). + +`scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым +надо вешать ОБА канала — `before_send` И `before_send_transaction`. +Starlette-интеграция кладёт тело запроса в `request_info["data"]` на +transaction-scope точно так же, как на error-scope (scope-обработчики для +transactions НЕ пропускаются — пропуск бывает только на availability-чеках). +Если повесить PII-scrub только на `before_send`, а `before_send_transaction` +оставить на голом `scrub_sensitive_query` — PII продолжит течь через +transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review, +воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в +транзакцию с полным телом). """ from __future__ import annotations +import logging import re from typing import Any from sentry_sdk.types import Event +logger = logging.getLogger(__name__) + +_REDACTED = "[REDACTED]" +# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ +# (client_name/client_phone/client_email/phone/email/name, #396) + company/ +# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля +# свободного текста (#2457-review). +_PII_KEYS = frozenset( + { + "client_name", + "client_phone", + "client_email", + "phone", + "email", + "name", + "company", + "message", + } +) + _SENSITIVE_PARAM_RE = re.compile( r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)", re.IGNORECASE, @@ -47,3 +93,56 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None: request["url"] = _redact(request["url"]) return event + + +def _scrub(obj: Any) -> None: + """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" + if isinstance(obj, dict): + for key, value in obj.items(): + if isinstance(key, str) and key.lower() in _PII_KEYS: + obj[key] = _REDACTED + else: + _scrub(value) + elif isinstance(obj, list): + for item in obj: + _scrub(item) + + +def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: + """Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction) + перед отправкой в GlitchTip. + + Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list), + заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event + (не None) — иначе SDK дропнет отчёт целиком. + """ + if not isinstance(event, dict): + return event + request = event.get("request") + if isinstance(request, dict): + _scrub(request.get("data")) + _scrub(event.get("extra")) + _scrub(event.get("contexts")) + return event + + +def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None: + """Composed `before_send` / `before_send_transaction` handler: PII-scrub + + URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module + docstring (#2457-review): transaction-scope несёт `request.data` точно так + же, как error-scope. + + try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в + `capture_internal_exceptions`, который при исключении внутри хендлера + ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что + редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты + покрытия редактора: лучше отправить событие в состоянии "сколько успели + отредактировать до сбоя", чем не отправить вообще и молча остаться без + сигнала в мониторинге. + """ + try: + scrub_pii_event(event, hint) + scrub_sensitive_query(event, hint) + except Exception: + logger.exception("sentry_scrub.scrub_event: handler failed, sending event as-is") + return event diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index d85d24ca..6d6f139e 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index c71725af..6087e4a4 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str: ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 1268bfe6..e52b8f5b 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event logger = logging.getLogger(__name__) @@ -23,6 +23,11 @@ logger = logging.getLogger(__name__) # чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного # вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review, + # см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker + # вообще не скрабил error-события (тут before_send не было), а + # before_send_transaction был на голом scrub_sensitive_query (только URL) — + # оба канала пропускали PII. sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -30,7 +35,8 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send_transaction=scrub_sensitive_query, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ CeleryIntegration(monitor_beat_tasks=True), SqlalchemyIntegration(), diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index 75538a31..73dd80c4 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -2,14 +2,21 @@ Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init() только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, -и что scrub_sensitive_query redact-ит api keys из URL spans. +что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event +redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/ +company/message) из request.data/extra/contexts, и что composed-хендлер +scrub_event реально повешен на ОБА канала (before_send И +before_send_transaction) в main.py/celery_app.py (#2457-review). """ import os +import pathlib from unittest.mock import patch import sentry_sdk +_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1] + def test_sdk_imports_without_error() -> None: """Все интеграции импортируются без ModuleNotFoundError.""" @@ -156,3 +163,255 @@ def test_scrub_handles_missing_spans() -> None: event: dict = {"request": {"url": "https://example.com"}} result = scrub_sensitive_query(event, {}) assert result["request"]["url"] == "https://example.com" + + +# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ──────────── +# +# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий +# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data, +# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk +# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором. + + +def test_scrub_pii_redacts_request_data() -> None: + """scrub_pii_event заменяет client_name/phone/email в request.data.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "client_name": "Иван Иванов", + "phone": "+79991234567", + "email": "ivan@example.com", + "address": "Екатеринбург, ул. Ленина 1", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["client_name"] == "[REDACTED]" + assert data["phone"] == "[REDACTED]" + assert data["email"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["address"] == "Екатеринбург, ул. Ленина 1" + + +def test_scrub_pii_redacts_pilot_request_company_and_message() -> None: + """scrub_pii_event заменяет company/message — свободный текст + PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают + телефоны/имена/адреса, а не только фиксированные name/phone/email + (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "company": "ООО Ромашка", + "message": "Меня зовут Иван, звоните на +79991234567", + "source": "landing", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["company"] == "[REDACTED]" + assert data["message"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["source"] == "landing" + + +def test_scrub_pii_redacts_client_prefixed_keys() -> None: + """Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) — + Птица их сегодня не использует, но одинаковый механизм на оба продукта + проще сопровождать (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "client_name": "Иван", + "client_phone": "+79991234567", + "client_email": "ivan@example.com", + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["client_name"] == "[REDACTED]" + assert extra["client_phone"] == "[REDACTED]" + assert extra["client_email"] == "[REDACTED]" + + +def test_scrub_pii_redacts_extra() -> None: + """scrub_pii_event заменяет PII-ключи в extra, не трогая остальное.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "phone": "+79990000000", + "email": "x@y.ru", + "name": "Пётр", + "lead_id": 42, + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["phone"] == "[REDACTED]" + assert extra["email"] == "[REDACTED]" + assert extra["name"] == "[REDACTED]" + assert extra["lead_id"] == 42 + + +def test_scrub_pii_is_case_insensitive() -> None: + """Сверка PII-ключей case-insensitive.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}} + result = scrub_pii_event(event, {}) + assert result["extra"]["Client_Name"] == "[REDACTED]" + assert result["extra"]["PHONE"] == "[REDACTED]" + + +def test_scrub_pii_redacts_nested_contexts() -> None: + """scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}} + result = scrub_pii_event(event, {}) + assert result["contexts"]["lead"]["email"] == "[REDACTED]" + assert result["contexts"]["trace"]["op"] == "http" + + +def test_scrub_pii_leaves_non_pii_untouched() -> None: + """scrub_pii_event не трогает event без PII-ключей.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": {"data": {"region": "66", "area_sqm": 50}}, + "extra": {"job": "geocode"}, + "level": "error", + } + result = scrub_pii_event(event, {}) + assert result["request"]["data"] == {"region": "66", "area_sqm": 50} + assert result["extra"] == {"job": "geocode"} + assert result["level"] == "error" + + +def test_scrub_pii_handles_missing_sections() -> None: + """scrub_pii_event не падает на пустом event.""" + from app.observability.sentry_scrub import scrub_pii_event + + result = scrub_pii_event({}, {}) + assert result == {} + + +def test_scrub_pii_handles_none_and_non_dict_sections() -> None: + """scrub_pii_event не бросает исключений на None/не-dict секциях.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"} + result = scrub_pii_event(event, {}) + assert result is event + + +def test_scrub_pii_returns_event_not_none() -> None: + """before_send должен вернуть event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + result = scrub_pii_event(event, {}) + assert result is not None + assert result is event + + +# ── scrub_event (composed before_send / before_send_transaction handler) ─────── +# +# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА +# канала (before_send И before_send_transaction). До #2457-review composed-хук +# висел только на before_send, а before_send_transaction оставался на голом +# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data +# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь +# через transaction-канал при glitchtip_traces_sample_rate > 0. + + +def test_scrub_event_composes_pii_and_url_secret_scrub() -> None: + """scrub_event применяет PII-scrub (ключ-based) И URL query-string secret + redact (regex) оба разом, не заменяя друг друга — разные классы данных.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = { + "request": { + "data": {"client_name": "Иван"}, + "url": "https://example.com?api_key=supersecret", + } + } + result = scrub_event(event, {}) + assert result is not None + assert result["request"]["data"]["client_name"] == "[REDACTED]" + assert "[REDACTED]" in result["request"]["url"] + assert "supersecret" not in result["request"]["url"] + + +def test_scrub_event_returns_event_not_none() -> None: + """scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_pii_event_exception() -> None: + """try/except в scrub_event — предохранитель: sentry_sdk оборачивает + before_send в capture_internal_exceptions, который при исключении ТОЛЬКО + логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал). + Если scrub_pii_event падает — scrub_event обязан вернуть event, а не + пробросить исключение дальше (#2457-review).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_pii_event", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_sensitive_query_exception() -> None: + """То же самое для второго шага композиции (URL-secret redact).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_sensitive_query", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +# ── wiring: before_send/before_send_transaction реально используют scrub_event ── +# +# Source-grep вместо мока sentry_sdk.init: main.py/celery_app.py вызывают +# sentry_sdk.init() на module-level import, поэтому мок пришлось бы ставить ДО +# импорта app.main — фрагильно и не переиспользуемо между тестами (модуль уже +# закэширован в sys.modules к моменту первого теста). Прямая проверка исходника +# — детерминированный, дешёвый и точный регрессионный гейт на саму строку, +# которую правил review (#2457). + + +def test_main_wires_scrub_event_to_both_channels() -> None: + """app/main.py: before_send И before_send_transaction ОБА на scrub_event.""" + text = (_BACKEND_ROOT / "app" / "main.py").read_text(encoding="utf-8") + assert "before_send=scrub_event" in text + assert "before_send_transaction=scrub_event" in text + + +def test_celery_app_wires_scrub_event_to_both_channels() -> None: + """app/workers/celery_app.py: before_send И before_send_transaction ОБА на + scrub_event (раньше before_send не было вообще).""" + text = (_BACKEND_ROOT / "app" / "workers" / "celery_app.py").read_text(encoding="utf-8") + assert "before_send=scrub_event" in text + assert "before_send_transaction=scrub_event" in text From 5ff06d25b46c70f82fc9370d814417da112ab726 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 21:48:06 +0300 Subject: [PATCH 073/130] =?UTF-8?q?feat(tradein/payments):=20=D0=BE=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=87=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D1=91=D1=82=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B3=D0=BE=D0=B4=20=E2=80=94=20retain=5Funtil=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B2=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мина: purge_expired_trade_in_data (сейчас enabled=false) удаляет строки WHERE expires_at < NOW() AND created_by IS NULL — это ровно популяция будущих платящих физлиц (владелец продаёт отчёт за 150 руб., отчёт должен жить год на нашей стороне, а не 24ч). Первый прогон после запуска продаж безвозвратно снёс бы оплаченное. Делается ДО платёжного кода, которого в этом PR нет: - migration 234: колонка trade_in_estimates.retain_until (NULL = неоплачено, бэкенд-бита-в-бит не меняется) + частичный индекс под purge-предикат. - config.py: trade_in_paid_retention_days=365 (ENV) — единственный источник "12 месяцев" для будущей оферты/экрана/SQL продления. - Единый гейт чтения ESTIMATE_READABLE_SQL + estimate_readable() — раньше SQL-фильтр (404) и Python-проверка (410) в trade_in.py уже разошлись по тексту ответа; текст "estimate expired (24h TTL)" убран (стал бы ложью при годовом хранении). - purge_expired_trade_in_data: retain_until IS NULL (не < NOW() — оплаченное не удаляем в принципе) + NOT EXISTS(payments) как независимая страховка + pre-flight, который считает оплаченных кандидатов и падает в mark_failed ДО первого батча при ненулевом результате. - PDF: "Ссылка доступна до …" только при retain_until IS NOT NULL; "ДЕЙСТВИТЕЛЕН ДО" (expires_at, актуальность расчёта) не тронут. - Фронт: retain_until прокинут в mapper (validUntil остаётся на expires_at). - privacy-страница: убрано устаревшее "механизма удаления нет" (неправда после #2547), добавлен срок 12 месяцев для оплаченных отчётов. Ни строчки платёжного кода. expires_at, trade_in_estimate_retention_hours, _DELETE_EXPIRED_LEADS_SQL не тронуты. --- tradein-mvp/backend/app/api/v1/trade_in.py | 39 +++++- tradein-mvp/backend/app/core/config.py | 11 ++ tradein-mvp/backend/app/schemas/trade_in.py | 4 + .../app/services/exporters/trade_in_pdf.py | 14 ++ .../app/tasks/purge_expired_trade_in_data.py | 76 +++++++++++ .../234_trade_in_estimates_retain_until.sql | 65 +++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + .../backend/tests/test_estimate_idor.py | 123 ++++++++++++++++- .../backend/tests/test_pdf_security.py | 33 +++++ .../tests/test_purge_expired_trade_in_data.py | 128 ++++++++++++++++-- .../src/app/mera-public/privacy/page.tsx | 34 +++-- tradein-mvp/frontend/src/app/v2/page.tsx | 2 +- .../src/components/trade-in/v2/fixtures.ts | 1 + .../src/components/trade-in/v2/mappers.ts | 3 + .../src/components/trade-in/v2/types.ts | 4 + tradein-mvp/frontend/src/types/trade-in.ts | 4 + 16 files changed, 512 insertions(+), 30 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 5335901b..05950b0c 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -52,6 +52,27 @@ logger = logging.getLogger(__name__) router = APIRouter() +# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404, +# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись +# по коду ответа; третий потребитель (`/r/`, PR-9) разошёлся бы +# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false +# в SQL — для всех существующих строк (retain_until IS NULL) поведение не +# меняется вообще. Не копировать это выражение по месту — только через +# константу/хелпер ниже. См. `mera-pr-d-spec.md` §1.3 в корне репо. +ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())" + + +def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool: + """Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД. + + tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf) — + `.replace(tzinfo=UTC)`, не переизобретается. + """ + now = datetime.now(tz=UTC) + if expires_at.replace(tzinfo=UTC) > now: + return True + return retain_until is not None and retain_until.replace(tzinfo=UTC) > now + def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None: """IDOR guard (#690): только владелец оценки или admin могут её читать. @@ -249,11 +270,11 @@ def get_estimate( """ row = db.execute( text( - """ + f""" SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, address, lat, lon, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, @@ -263,7 +284,7 @@ def get_estimate( asking_to_sold_ratio, ratio_basis, created_by, created_at FROM trade_in_estimates WHERE id = CAST(:id AS uuid) - AND expires_at > NOW() + AND {ESTIMATE_READABLE_SQL} """ ), {"id": str(estimate_id)}, @@ -372,6 +393,7 @@ def get_estimate( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, @@ -433,7 +455,7 @@ def estimate_pdf( SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, @@ -453,8 +475,12 @@ def estimate_pdf( _assert_estimate_access(row.created_by, x_authenticated_user) - if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): - raise HTTPException(status_code=410, detail="estimate expired (24h TTL)") + # PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше + # здесь была независимая Python-проверка expires_at, разошедшаяся с SQL- + # фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при + # годовом retain_until упоминание 24ч в ответе API стало бы ложью. + if not estimate_readable(row.expires_at, row.retain_until): + raise HTTPException(status_code=410, detail="estimate expired") from app.services.estimator import _qc_geo_to_precision @@ -477,6 +503,7 @@ def estimate_pdf( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 1702d860..9a9b4ebf 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -836,6 +836,17 @@ class Settings(BaseSettings): # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. trade_in_lead_retention_days: int = 180 + # ── PR-D1: платный отчёт живёт год (retain_until, migration 234) ──────── + # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ + # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность + # расчёта, 24ч). НЕ трогает expires_at — см. migration 234 докстринг и + # `mera-pr-d-spec.md` §1.1/§1.2 в корне репо. Единственный источник числа + # «12 месяцев»: текст оферты (content.ts), текст экрана S4 и 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 по всей таблице — долгая блокировка на большом бэклоге). Задача сама # крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) — diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index ad4811d0..c7620ece 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -196,6 +196,10 @@ class AggregatedEstimate(BaseModel): analogs: list[AnalogLot] actual_deals: list[AnalogLot] # реальные продажи last 12 mo expires_at: datetime + # PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности + # расчёта — тот остаётся expires_at (не путать, см. migration 234). + # NULL = неоплачено (весь текущий трафик, B2B pilots включительно). + retain_until: datetime | None = None # ── Дополнительные метаданные ── target_address: str | None = None # geocoded full address target_lat: float | None = None diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 425dda5f..8282f83c 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -1050,6 +1050,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s ) report_num = _report_number(estimate) + # PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа + # (retain_until), НЕ путать со «Срок действия данных» (expires_at, + # актуальность расчёта) над ней — эта строка не трогается. Рендерится + # ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий + # трафик — не видят этой строки вообще, поведение бит-в-бит текущее). + retain_until_row = ( + f'' + f'" + if estimate.retain_until is not None + else "" + ) + # Короткий адрес (для cover): берём первую часть до запятой full_address = input_snapshot.get("address", "—") address_short = full_address.split(",")[0:3] @@ -1146,6 +1159,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s + {retain_until_row} diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index 420911ec..5a96989c 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -54,6 +54,17 @@ BATCHING (не единый DELETE по всей таблице): don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier committed batches deleted (correct, not rolled back) and mark_failed records the partial counters reached so far. + +PR-D1 (2026-08-06, payments retention -- see `mera-pr-d-spec.md` §1 at repo root): + 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. 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 a paid candidate -- see the SQL constants and + `_preflight_paid_candidates` below for the mechanics. No payment code lives in + this file; `retain_until` is set by the (separate, not-yet-existing) payment + fulfillment code. """ from __future__ import annotations @@ -74,6 +85,21 @@ logger = logging.getLogger(__name__) # remainder simply drains on the next nightly run (idempotent, no data loss risk). _DEFAULT_MAX_BATCHES = 20 +# +# PR-D1 (2026-08-06): два независимые предохранителя добавлены к тому же +# предикату, ПЕРЕД тем как платёжный код появился в проекте (мина уже была +# заряжена, см. `mera-pr-d-spec.md` §1 в корне репо): +# 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная +# строка (retain_until IS NOT NULL, migration 234) не удаляется джобой +# В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем +# через год после первой продажи. `retain_until` ставится сервисным +# кодом платёжного контура (ещё не существует в этом PR) на now() + +# settings.trade_in_paid_retention_days. +# 2. `NOT EXISTS (payments)` — независимая страховка на случай, если выдача +# забыла проставить retain_until (баг/гонка/ручной INSERT): строка, +# которой коснулись деньги, переживёт джобу даже без корректного (1). +# `payments` создана migration 233 (payments_estimate_idx — дешёвый терм). +# См. также _preflight_paid_candidates ниже — та же логика ДО первого батча. _DELETE_EXPIRED_ESTIMATES_SQL = text( """ DELETE FROM trade_in_estimates @@ -81,12 +107,31 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text( SELECT id FROM trade_in_estimates WHERE expires_at < NOW() AND created_by IS NULL + AND retain_until IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payments p WHERE p.estimate_id = trade_in_estimates.id + ) ORDER BY expires_at LIMIT CAST(:batch_size AS int) ) """ ) +# PR-D1 pre-flight (см. _preflight_paid_candidates): считает по БАЗОВОМУ +# (пред-PR-D1) предикату purge -- `expires_at < NOW() AND created_by IS NULL`, +# БЕЗ retain_until/NOT EXISTS -- специально ШИРЕ итогового DELETE-предиката +# выше, чтобы поймать именно случай "retain_until не проставлен, а деньги +# были" (а не только штатно защищённые retain_until IS NOT NULL строки, +# которые и так не попали бы под DELETE). +_PREFLIGHT_PAID_CANDIDATES_SQL = text( + """ + SELECT count(*) FROM trade_in_estimates e + WHERE e.expires_at < NOW() + AND e.created_by IS NULL + AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id) + """ +) + _DELETE_EXPIRED_LEADS_SQL = text( """ DELETE FROM trade_in_leads @@ -135,6 +180,19 @@ def _drain_expired( break # caught up -- fewer expired rows left than one batch +def _preflight_paid_candidates(db: Session) -> int: + """PR-D1 safety gate: count purge-candidates (base predicate) that have a payments row. + + Runs BEFORE any DELETE batch. A non-zero result means at least one estimate that + would have matched the OLD (pre-PR-D1) purge predicate was actually touched by + money -- either `retain_until` failed to be set (fulfillment bug/race/manual + INSERT) or something inconsistent happened. Either way this run must not delete + anything; see `purge_expired_trade_in_data` below, which aborts before the first + batch when this returns non-zero. + """ + return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one() + + def purge_expired_trade_in_data( db: Session, run_id: int, @@ -148,10 +206,28 @@ def purge_expired_trade_in_data( deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed). Returns {"estimates_deleted": N, "leads_deleted": M}. + + PR-D1 pre-flight (see `_preflight_paid_candidates`): if any purge-candidate + estimate has a `payments` row, the run aborts BEFORE the first DELETE batch -- + zero rows deleted, `mark_failed` records why. This is deliberately checked + outside the `try` below so it can never be caught and silently re-reported as a + generic mid-run failure -- it is a distinct, actionable pre-condition failure. """ batch_size = batch_size or settings.trade_in_purge_batch_size max_batches = max_batches or _DEFAULT_MAX_BATCHES counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0} + + paid_candidates = _preflight_paid_candidates(db) + if paid_candidates: + error = ( + f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates " + "row(s) have a matching payments row (retain_until may be unset) -- " + "refusing to run, zero rows deleted" + ) + logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error) + runs_mod.mark_failed(db, run_id, error, counters) + raise RuntimeError(error) + try: _drain_expired( db, diff --git a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql new file mode 100644 index 00000000..2d2e3d4f --- /dev/null +++ b/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql @@ -0,0 +1,65 @@ +-- 234_trade_in_estimates_retain_until.sql +-- PR-D1 «Ретеншен: оплаченное живёт год, purge его не трогает» — см. +-- `mera-pr-d-spec.md` §1 в корне репо (обоснования там, здесь только SQL). +-- Номер сверен и по `forgejo/main`, и по всем открытым PR-веткам на момент +-- написания (последняя занятая — 233_payments.sql) — см. урок в шапке того +-- же файла про то, как коллизия 228/229/231/232 обнаруживается поздно. +-- +-- ── 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. + +BEGIN; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS retain_until timestamptz; + +COMMENT ON COLUMN trade_in_estimates.retain_until IS + 'До какого момента строку НЕЛЬЗЯ удалять и ссылка обязана открываться ' + '(оплаченный доступ). Семантика expires_at не меняется: это дата ' + 'актуальности РАСЧЁТА (24ч), она печатается в PDF. NULL = неоплачено, ' + 'поведение бит-в-бит текущее. Задаётся сервисным кодом платёжного контура ' + '(отдельный PR) на now() + trade_in_paid_retention_days (config.py).'; + +-- Частичный индекс под predicate purge-джобы (app/tasks/purge_expired_trade_in_data.py): +-- WHERE created_by IS NULL AND retain_until IS NULL AND expires_at < NOW(). +CREATE INDEX IF NOT EXISTS trade_in_estimates_purge_idx + ON trade_in_estimates (expires_at) + WHERE created_by IS NULL AND retain_until IS NULL; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d71c6d7..27b06836 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -231,3 +231,4 @@ # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. 233_payments.sql +234_trade_in_estimates_retain_until.sql diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 40234a3e..1dc6aaf9 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -51,8 +51,13 @@ def trade_in_app() -> FastAPI: return application -def _make_estimate_row(created_by: str | None) -> SimpleNamespace: - """A trade_in_estimates row with the full column set the endpoints read.""" +def _make_estimate_row(created_by: str | None, retain_until: object = None) -> SimpleNamespace: + """A trade_in_estimates row with the full column set the endpoints read. + + retain_until defaults to None (PR-D1, migration 234) -- 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 return SimpleNamespace( @@ -69,6 +74,7 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: sources_used=["avito"], data_freshness_minutes=10, expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=retain_until, address="ул. Тестовая, 1", lat=56.8, lon=60.6, @@ -597,3 +603,116 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) headers={"X-Authenticated-User": "attacker"}, ) assert resp.status_code == 404 + + +# ── PR-D1: retention gate unification (retain_until, mera-pr-d-spec.md §1.3) ── + + +def test_estimate_readable_sql_uses_disjunction() -> None: + """Single definition — OR retain_until, not a hand-copied expression.""" + from app.api.v1.trade_in import ESTIMATE_READABLE_SQL + + assert "expires_at > NOW()" in ESTIMATE_READABLE_SQL + assert "retain_until > NOW()" in ESTIMATE_READABLE_SQL + assert " OR " in ESTIMATE_READABLE_SQL + + +def test_get_estimate_sql_built_from_shared_constant() -> None: + """GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a + hand-copied literal — regression guard against the two gates drifting apart + again (that's exactly what happened before this PR: 404 here, 410 in /pdf).""" + import inspect + + from app.api.v1.trade_in import get_estimate + + src = inspect.getsource(get_estimate) + assert "ESTIMATE_READABLE_SQL" in src + assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant" + assert "retain_until" in src, "SELECT must also fetch retain_until" + + +def test_estimate_pdf_select_includes_retain_until_column() -> None: + import inspect + + from app.api.v1.trade_in import estimate_pdf + + assert "retain_until" in inspect.getsource(estimate_pdf) + + +@pytest.mark.parametrize( + ("expires_delta_hours", "retain_delta_days", "expected"), + [ + (12, None, True), # not expired, unpaid — current B2B/B2C behaviour, unchanged + (-1, None, False), # expired, unpaid — current behaviour (404/410), unchanged + (-1, 365, True), # expired but PAID — new: readable + (12, 365, True), # not expired AND paid — readable + (-1, -1, False), # expired, and the (hypothetical) retain_until also in the past + ], +) +def test_estimate_readable_truth_table( + expires_delta_hours: int, retain_delta_days: int | None, expected: bool +) -> None: + from datetime import UTC, datetime, timedelta + + from app.api.v1.trade_in import estimate_readable + + expires_at = datetime.now(tz=UTC) + timedelta(hours=expires_delta_hours) + retain_until = ( + datetime.now(tz=UTC) + timedelta(days=retain_delta_days) + if retain_delta_days is not None + else None + ) + assert estimate_readable(expires_at, retain_until) is expected + + +def test_pdf_expired_but_paid_returns_200(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until in the future → PDF still downloads + (200). Exactly the scenario PR-D1 exists for: a paid report must outlive + the 24h expires_at link TTL.""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = datetime.now(tz=UTC) + timedelta(days=300) + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + + +def test_pdf_expired_unpaid_returns_410_without_ttl_text(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until NULL (unpaid, unchanged behaviour) → + 410, and the detail text no longer claims a specific '24h TTL' (would be a + lie now that retain_until exists for paid rows).""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 410 + assert resp.json()["detail"] == "estimate expired" + assert "24h" not in resp.json()["detail"] + assert "TTL" not in resp.json()["detail"] + + +def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI) -> None: + """Response schema exposes retain_until (nullable) — schemas/trade_in.py.""" + row = _make_estimate_row(created_by="kopylov") # retain_until defaults to None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["retain_until"] is None diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index a97b017f..da5722ec 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -445,3 +445,36 @@ def test_brand_not_taken_from_query_param_docstring() -> None: assert ( "brand" not in param_names ), "estimate_pdf should NOT have a 'brand' query param after #7 fix" + + +# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ── + + +def test_cover_no_retain_until_row_when_unpaid() -> None: + """retain_until IS NULL (default, all current traffic) → no 'Ссылка доступна + до' row at all — B2B regression guard, cover renders bit-for-bit as before.""" + est = _estimate() + assert est.retain_until is None + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" not in html + + +def test_cover_renders_retain_until_row_when_paid() -> None: + """retain_until IS NOT NULL → 'Ссылка доступна до ' row present, with + its OWN date (not conflated with 'Срок действия данных' / expires_at).""" + retain = datetime(2027, 8, 6, tzinfo=UTC) + est = _estimate(retain_until=retain) + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" in html + assert "06.08.2027" in html + + +def test_expires_date_unaffected_by_retain_until() -> None: + """«ДЕЙСТВИТЕЛЕН ДО» (running footer, _expires_date) stays wired to + expires_at regardless of retain_until — it is data-actuality, not the + paid-access retention window, and must not move when a report is paid.""" + expires = datetime.now(UTC) + timedelta(hours=24) + est_unpaid = _estimate(expires_at=expires) + est_paid = _estimate(expires_at=expires, retain_until=expires + timedelta(days=365)) + assert mod._expires_date(est_unpaid) == expires.date() + assert mod._expires_date(est_paid) == expires.date() diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index 07a85aa9..caa1b207 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -7,9 +7,20 @@ Covers app/tasks/purge_expired_trade_in_data.py: - both tables (trade_in_estimates, trade_in_leads) get drained - failure path: rollback + mark_failed with partial counters, exception re-raised - SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap + - PR-D1 (payments retention, mera-pr-d-spec.md §1.4): 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 a paid purge-candidate. Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched runs_mod.mark_done/mark_failed). + +PR-D1 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 paid +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 @@ -34,18 +45,30 @@ class _FakeResult: def __init__(self, rowcount: int) -> None: self.rowcount = rowcount + def scalar_one(self) -> int: + """Supports the PR-D1 pre-flight `SELECT count(*) ... .scalar_one()` call.""" + return self.rowcount + class _FakeDB: - """Pops rowcounts in call order -- caller supplies the exact sequence expected.""" + """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.preflight_count = preflight_count self.executed: list[tuple[Any, Any]] = [] self.commits = 0 self.rolled_back = False def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: self.executed.append((stmt, params)) + if stmt is task_mod._PREFLIGHT_PAID_CANDIDATES_SQL: + return _FakeResult(self.preflight_count) return _FakeResult(self._rowcounts.pop(0)) def commit(self) -> None: @@ -83,7 +106,9 @@ def test_stops_when_batch_below_size(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([3, 0]) out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type] assert out == {"estimates_deleted": 3, "leads_deleted": 0} - 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 marked["counters"] == out @@ -94,7 +119,7 @@ def test_loops_until_below_batch_size(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([5, 5, 2, 5, 1]) out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type] assert out == {"estimates_deleted": 12, "leads_deleted": 6} - assert len(db.executed) == 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" @@ -106,14 +131,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 out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type] assert out == {"estimates_deleted": 15, "leads_deleted": 15} - assert len(db.executed) == 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: _patch_runs(monkeypatch) db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type] - _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["batch_size"] == task_mod.settings.trade_in_purge_batch_size @@ -125,8 +152,9 @@ def test_drains_both_tables_in_order(monkeypatch: pytest.MonkeyPatch) -> None: _patch_runs(monkeypatch) db = _FakeDB([0, 0]) task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type] - first_sql = str(getattr(db.executed[0][0], "text", db.executed[0][0])) - second_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + # index 0 is now the pre-flight call; DELETE batches shifted to 1/2. + first_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + second_sql = str(getattr(db.executed[2][0], "text", db.executed[2][0])) assert "trade_in_estimates" in first_sql assert "trade_in_leads" in second_sql @@ -141,6 +169,81 @@ def test_estimates_sql_is_delete_not_update() -> None: assert not re.search(r":\w+::", sql) +# ── PR-D1 (mera-pr-d-spec.md §1.4): 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_is_wider_than_delete_predicate() -> None: + """Pre-flight counts by the BASE (pre-PR-D1) predicate -- expires_at < NOW() + AND created_by IS NULL, WITHOUT retain_until/NOT EXISTS -- so it also catches + the case those two terms exist specifically to guard against (retain_until + unset despite a payments row existing).""" + sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text + assert "expires_at < NOW()" in sql + assert "created_by IS NULL" in sql + assert "retain_until" not 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 PR-D1 — + leads have their own retention deadline (migration 231) and are explicitly + out of scope (mera-pr-d-spec.md §1.4: '_DELETE_EXPIRED_LEADS_SQL — оставить + дословно').""" + expected = ( + "\n DELETE FROM trade_in_leads\n WHERE id IN (\n" + " SELECT id FROM trade_in_leads\n" + " WHERE expires_at < NOW()\n" + " ORDER BY expires_at\n" + " LIMIT CAST(:batch_size AS int)\n )\n " + ) + assert task_mod._DELETE_EXPIRED_LEADS_SQL.text == expected + + def test_leads_sql_is_delete_not_update() -> None: sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text assert "DELETE FROM trade_in_leads" in sql @@ -199,13 +302,14 @@ def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch) class _BoomDB(_FakeDB): def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: - # First batch (estimates) succeeds and commits; second call (still - # draining estimates, or first leads call) explodes. - if len(self.executed) >= 1: + # Call #1 (pre-flight, preflight_count=0) and call #2 (first estimates + # batch) succeed and commit; call #3 (still draining estimates, or + # first leads call) explodes. +1 vs pre-PR-D1 to admit the pre-flight. + if len(self.executed) >= 2: raise RuntimeError("db exploded") return super().execute(stmt, params) - db = _BoomDB([5]) # only ONE successful batch before the boom + db = _BoomDB([5]) # only ONE successful DELETE batch before the boom with pytest.raises(RuntimeError, match="db exploded"): task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type] diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx index 82b35a28..86fd3cec 100644 --- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx @@ -32,11 +32,21 @@ import { safeUrl } from "@/lib/safeUrl"; * квалификация — не наше дело: заявка привязывается к конкретному расчёту * (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее * сохранённым адресом. Финальную формулировку даёт юрист. - * - «мы удалим ваш телефон и заявку». Механизма удаления в бэкенде НЕТ: - * ни `DELETE FROM trade_in_leads/trade_in_estimates` в коде, ни - * retention/erasure-джоба среди `app/tasks/**` (проверено grep'ом); - * `expires_at` применяется только на чтении. Обещать удаление до появления - * процедуры нельзя — это самое дорогое из обещаний. + * - «мы обещаем удалить всё вообще» без оговорок. После #2547 механизм + * удаления в бэкенде ЕСТЬ: `app/services/data_erasure.py` (ручное + * удаление по обращению — по estimate_id/телефону/Telegram chat id) и + * `app/tasks/purge_expired_trade_in_data.py` (автоматическое удаление по + * истечении срока хранения). Прежняя формулировка «механизма нет» стала + * неправдой и здесь больше не пишется. Оговорка, которая остаётся честной: + * копия сообщения в Telegram-группе поддержки этим механизмом не + * стирается (см. докстринг `data_erasure.py`) — сюда её не выносим + * (излишняя техническая деталь для публичной страницы), но это ограничение + * реальное и известное. + * - PR-D1 (`retain_until`, `trade_in_paid_retention_days`): срок «12 месяцев» + * ниже читается из той же настройки, что и оферта/SQL продления — + * см. `mera-pr-d-spec.md` §1.2 в корне репо. Платёжного кода в этом PR + * нет — срок описан на будущее, синхронно с privacy-обязательством #1.7 + * того же дока, а не «потом». * * Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока * расчёт выключен, адрес действительно не покидает браузер; после включения это @@ -136,10 +146,16 @@ export default function MeraPublicPrivacyPage() { ) : null}

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

Оператор

diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 1476a7f7..38c7df68 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -123,7 +123,7 @@ const brackets: { key: string; style: CSSProperties }[] = [ // Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there // is an estimate. Dashes — never the design fixtures (which would read as a fake // real report). -const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" }; +const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—", retainUntil: null }; const EMPTY_OBJECT: ObjectInfo = { address: "—", city: "", diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts index 7c66e2ba..6e3de551 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts @@ -30,6 +30,7 @@ export const report: Report = { id: "9b9e2737", date: "20.06.2026", validUntil: "21.06.2026", + retainUntil: null, }; export const object: ObjectInfo = { diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 81e9ff94..d8a9e676 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -780,6 +780,9 @@ export function mapReport(e: AggregatedEstimate): Report { ? fmtDate(e.created_at) : fmtDateShift(e.expires_at, -24), // pre-BE-1 fallback: no real created_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, }; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 81ca7422..99d961b4 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -5,6 +5,10 @@ export interface Report { id: string; date: 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 { diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 7db71758..ce3282bd 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -172,6 +172,10 @@ export interface AggregatedEstimate { analogs: AnalogLot[]; // top 5-10 actual_deals: AnalogLot[]; // last 12 mo expires_at: string; // ISO datetime + // PR-D1: срок жизни ссылки/строки (оплаченный доступ, backend migration 234), + // НЕ путать с expires_at (актуальность расчёта). null = неоплачено (текущий + // трафик целиком). + retain_until?: string | null; // ISO datetime // ── Метаданные ── target_address: string | null; target_lat: number | null; From 76c3f5c110f329b7e4e60d8480ca39fcefeb8dad Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 6 Aug 2026 18:59:50 +0000 Subject: [PATCH 074/130] =?UTF-8?q?chore(frontend):=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D1=83=D1=80=D1=8B=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB?= =?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20+=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=81=D0=B8=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D0=B5=D0=B2=D1=88=D0=B8=D1=85=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20(#2747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit МЕРА: у 8 компонентов витрины v2 проп data больше не имеет дефолта из fixtures.ts — при сбое передачи данных компонент обязан упасть на TS-ошибке, а не отрисовать выдуманные числа на платном экране оценки. Цепная правка в SectionOverlay (4 поля стали обязательными в такт с детьми). Птица: удалены 6 осиротевших компонентов (ноль импортов подтверждён репо-wide), подчищены 2 ссылающихся комментария. Проверено ревьюером: tsc --noEmit и next lint реально отработали на 91b460b1 (лог задачи 18031), vitest 32/264 зелёные (лог 18033); storybook в репозитории отсутствует вовсе — «unwired/storybook usage» как обоснование дефолтов никогда не имело потребителя; ui-preview/estimate использует v1-компоненты со своей локальной фикстурой и не задет. --- .../src/components/site-finder/ScoreCard.tsx | 5 - .../analysis/AnalysisBreadcrumb.tsx | 82 ---- .../site-finder/analysis/AnalysisSidebar.tsx | 266 ------------ .../site-finder/analysis/MassingEconomics.tsx | 410 ------------------ .../site-finder/analysis/UserAvatar.tsx | 104 ----- .../ptica/PticaPlaceholderPanel.tsx | 22 - frontend/src/lib/concept-api.ts | 2 +- frontend/src/lib/nspdLinks.ts | 2 +- .../components/trade-in/v2/AnalyticsView.tsx | 14 +- .../src/components/trade-in/v2/CacheView.tsx | 14 +- .../src/components/trade-in/v2/Footer.tsx | 11 +- .../src/components/trade-in/v2/HeroBar.tsx | 11 +- .../components/trade-in/v2/HistoryView.tsx | 17 +- .../components/trade-in/v2/ObjectSummary.tsx | 16 +- .../components/trade-in/v2/ResultPanel.tsx | 23 +- .../components/trade-in/v2/SectionOverlay.tsx | 13 +- .../components/trade-in/v2/SourcesView.tsx | 27 +- 17 files changed, 66 insertions(+), 973 deletions(-) delete mode 100644 frontend/src/components/site-finder/ScoreCard.tsx delete mode 100644 frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx delete mode 100644 frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx delete mode 100644 frontend/src/components/site-finder/analysis/MassingEconomics.tsx delete mode 100644 frontend/src/components/site-finder/analysis/UserAvatar.tsx delete mode 100644 frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx deleted file mode 100644 index 2229b1f1..00000000 --- a/frontend/src/components/site-finder/ScoreCard.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// ScoreCard is superseded by the tabbed dashboard in page.tsx. -// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab. -// File kept to avoid breaking any external imports; exports an empty stub. - -export {}; diff --git a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx deleted file mode 100644 index b72e33c9..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; - -interface AnalysisBreadcrumbProps { - cadNum: string; -} - -export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) { - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx deleted file mode 100644 index 487f5cd6..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import { ExternalLink } from "lucide-react"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface SubSection { - id: string; - label: string; -} - -interface NavSection { - id: string; - label: string; - sub?: SubSection[]; -} - -// ── Config ──────────────────────────────────────────────────────────────────── - -const NAV_SECTIONS: NavSection[] = [ - { id: "section-1", label: "1. Объект" }, - { id: "section-2", label: "2. Земля и риски" }, - { - id: "section-3", - label: "3. Рынок", - sub: [ - { id: "section-3-1", label: "3.1 Настройки выборки" }, - { id: "section-3-2", label: "3.2 Планировки" }, - { id: "section-3-3", label: "3.3 Остатки и скорость" }, - ], - }, - { id: "section-4", label: "4. Оценка" }, - { id: "section-5", label: "5. Атмосфера" }, - { - id: "section-6", - label: "6. Прогноз", - sub: [ - { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, - { id: "section-6-2", label: "6.2 Сценарии" }, - { id: "section-6-3", label: "6.3 Уверенность" }, - { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, - { id: "section-6-5", label: "6.5 Прозрачность скоринга" }, - { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, - ], - }, -]; - -// All section IDs in scroll order (for IntersectionObserver) -const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) => - s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id], -); - -// ── Component ───────────────────────────────────────────────────────────────── - -export function AnalysisSidebar() { - const [activeId, setActiveId] = useState(ALL_SECTION_IDS[0]); - const observerRef = useRef(null); - - // Scrollspy via IntersectionObserver - useEffect(() => { - const candidates = ALL_SECTION_IDS.map((id) => - document.getElementById(id), - ).filter((el): el is HTMLElement => el !== null); - - if (candidates.length === 0) return; - - // Track which sections are visible; pick topmost visible one - const visible = new Set(); - - observerRef.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - visible.add(entry.target.id); - } else { - visible.delete(entry.target.id); - } - }); - - // Pick the topmost section that is currently visible - const next = ALL_SECTION_IDS.find((id) => visible.has(id)); - if (next) setActiveId(next); - }, - { - root: null, - // Trigger when section top enters top 60% of viewport - rootMargin: "-8px 0px -40% 0px", - threshold: 0, - }, - ); - - candidates.forEach((el) => observerRef.current!.observe(el)); - - return () => { - observerRef.current?.disconnect(); - }; - }, []); - - function handleAnchorClick( - e: React.MouseEvent, - targetId: string, - ) { - e.preventDefault(); - const el = document.getElementById(targetId); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - setActiveId(targetId); - } - - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx deleted file mode 100644 index 272c6239..00000000 --- a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx +++ /dev/null @@ -1,410 +0,0 @@ -"use client"; - -/** - * MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b, - * epic #1953). - * - * Driven by the interactive 3D MassingScene: every time the user drags the - * этажность / секций sliders, Section7Concept maps the scene's `computeModel` - * result + the analysis context into a `MassingProgram` and hands it here via - * `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and - * render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость / - * прибыль / ROI). - * - * Robustness: - * • debounce — slider drags fire many programs; only the settled one is sent. - * • latest-wins — an in-flight request is superseded by a newer one via a - * monotonic request id; a stale response is dropped, never overwriting a - * fresher result (mutateAsync + id guard, no UI flicker from out-of-order). - * • last-good — on a failed recompute we keep the last successful values and - * show a subtle inline note rather than blanking the panel. - * • skeleton — a plain grey fade KPI grid while the FIRST recompute is in - * flight (no shimmer, per ui-conventions). - * - * Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but - * this strip uses the light KPI tokens via the shared KpiCard. - */ - -import { useEffect, useRef, useState } from "react"; -import { AlertTriangle } from "lucide-react"; - -import { KpiCard } from "@/components/analytics/KpiCard"; -import { Section } from "@/components/analytics/Section"; -import { - priceSourceCaption, - useRecomputeMassing, - type FinancialModel, - type MassingProgram, - type MassingRecomputeOutput, - type Teap, -} from "@/lib/concept-api"; - -const DEBOUNCE_MS = 250; - -// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ───────── - -const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); - -/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */ -function formatMoneyCompact(rub: number): string { - const abs = Math.abs(rub); - if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`; - if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`; - return `${nf.format(Math.round(rub))} ₽`; -} - -function formatInt(n: number): string { - return nf.format(Math.round(n)); -} - -function formatPct(fraction: number): string { - return `${(fraction * 100).toFixed(1)}%`; -} - -function formatFar(far: number): string { - return far.toLocaleString("ru-RU", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); -} - -// ── KPI grid ─────────────────────────────────────────────────────────────────── - -interface KpiGridProps { - teap: Teap; - financial: FinancialModel; - /** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */ - farTarget: number; - /** True → факт-КСИТ превышает регламентный потолок (model.over). */ - ksitOver: boolean; - /** Dim the strip while a fresher recompute is in flight (last-good values). */ - stale: boolean; -} - -function KpiGrid({ - teap, - financial, - farTarget, - ksitOver, - stale, -}: KpiGridProps) { - const netPositive = - financial.net_profit_rub > 0 - ? true - : financial.net_profit_rub < 0 - ? false - : null; - - return ( -
- {/* ТЭП */} -
- - - - -
- - {/* Финмодель */} -
- - - - - 0 - ? true - : financial.npv_rub < 0 - ? false - : null, - }} - hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct( - financial.discount_rate_used, - )} годовых).`} - /> - financial.discount_rate_used - ? true - : false, - }} - hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)." - /> - -
-
- ); -} - -// ── Skeleton (grey fade, no shimmer — ui-conventions) ────────────────────────── - -function SkeletonGrid() { - const cells = Array.from({ length: 7 }); - return ( -
и +# listing_sources.last_seen_at замерено на проде и не превышает 0.5 суток в среднем +# (максимум 0), что на шкале 30-70 суток шум. +# +# КВАНТИЛЬ — калибровочная ручка, не догма. 0.99 подобран по требованию «пол обязан +# накрыть 127 доказанных ложных снятий», у которых возраст был 29.9..30.3: замер +# того же запроса на проде даёт 34.0 для cian/vtorichka и 74.3 для yandex/vtorichka. +# Ниже 0.99 опускать нельзя без нового замера. Ручка живёт в default_params +# расписания (revisit_floor_quantile), 0 -> пол выключен. +# +# ПОБОЧНЫЙ ЭФФЕКТ, КОТОРЫЙ ЗДЕСЬ НАМЕРЕННЫЙ: после провала сбора хвост разрывов +# распухает (свип разгребает завал и находит очень старые строки), пол поднимается, +# и деактивация замирает сама — без отдельного детектора банов. Когда завал разобран, +# хвост схлопывается и пол опускается обратно. Это ровно то поведение, которого +# issue просил от «гейта по банам», но выраженное через результат, а не через причину. +# +# ПОТОЛОК: пол не может превысить глубину истории снимков. Если снимок за нужную +# дату не писался (дыры на проде есть — 30.07, 01.08), берётся ближайший более +# ранний; при полном отсутствии снимков пол не считается и TTL остаётся как задан. +DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 + +_REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))" + + +def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any: + """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива. + + Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт + разрыв переобхода в сутках; берём его квантиль по срезу source+segments. + Только строки, у которых свежесть реально сдвинулась, — то есть выжившие, + а не «мы к ним не приходили». + + staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings. + Значения — param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type). + """ + segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER if with_segments else "" + return text( + f""" + SELECT percentile_disc(CAST(:revisit_quantile AS double precision)) + WITHIN GROUP ( + ORDER BY EXTRACT(epoch FROM (l.{staleness_column} - prev.last_seen_at)) + / 86400.0 + ) + FROM listings l + JOIN listing_sources ls + ON ls.listing_id = l.id + AND ls.ext_source = l.source + JOIN listing_source_snapshots prev + ON prev.listing_source_id = ls.id + AND prev.snapshot_date = ( + SELECT max(snapshot_date) + FROM listing_source_snapshots + WHERE snapshot_date + <= CURRENT_DATE - CAST(:health_window_days AS integer) + ) + WHERE l.source = :listing_source + AND l.{staleness_column} + > NOW() - CAST(:health_window_days || ' days' AS interval) + AND l.{staleness_column} > prev.last_seen_at{segment_filter} + """ + ) + + def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any: """SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE. @@ -209,6 +311,7 @@ def deactivate_stale_listings( staleness_column: str = "last_seen_at", min_confirmations: int = 0, health_window_days: int = _HEALTH_WINDOW_DAYS, + revisit_floor_quantile: float = 0.0, ) -> dict[str, int]: """Пометить is_active=false объявления, чья свежесть старше ttl_days дней. @@ -229,6 +332,11 @@ def deactivate_stale_listings( вызывают старые тесты и совместимая обёртка); реальные значения приходят из default_params расписания, см. миграцию 219 и комментарий выше. health_window_days: окно подтверждений для гейта, суток. Дефолт 3. + revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659). + Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой; + эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так + вызывают старые тесты и совместимая обёртка), рабочее значение — + DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше. Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots @@ -236,7 +344,8 @@ def deactivate_stale_listings( Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками). Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N, - "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. + "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял + TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Raises: ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, @@ -292,6 +401,44 @@ def deactivate_stale_listings( ) return counters + # Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же + # срезу. Поднимает порог, никогда не опускает: max(), а не замена. + effective_ttl_days = ttl_days + if revisit_floor_quantile > 0: + floor_params: dict[str, Any] = { + "listing_source": listing_source, + "health_window_days": health_window_days, + "revisit_quantile": revisit_floor_quantile, + } + if segments is not None: + floor_params["segments"] = segments + floor_days = db.execute( + _build_revisit_floor_sql(staleness_column, with_segments=segments is not None), + floor_params, + ).scalar() + # NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках). + # Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего. + if floor_days is not None: + counters["revisit_floor_days"] = ceil(float(floor_days)) + effective_ttl_days = max(ttl_days, counters["revisit_floor_days"]) + counters["ttl_days_effective"] = effective_ttl_days + if effective_ttl_days > ttl_days: + logger.warning( + "deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: " + "свип за %d сут доказал живой строку, молчавшую %d сут " + "(квантиль %.3f, segments=%r) — при ttl_days=%d снятие означало бы " + "«мы не дошли», а не «объявление снято»", + listing_source, + run_id, + ttl_days, + effective_ttl_days, + health_window_days, + counters["revisit_floor_days"], + revisit_floor_quantile, + segments, + ttl_days, + ) + # segments is None -> все сегменты (поведение avito). segments=[...] -> только # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), @@ -299,7 +446,7 @@ def deactivate_stale_listings( if segments is not None: params: dict[str, Any] = { "listing_source": listing_source, - "ttl_days": ttl_days, + "ttl_days": effective_ttl_days, "segments": segments, "run_id": run_id, } @@ -307,7 +454,7 @@ def deactivate_stale_listings( else: params = { "listing_source": listing_source, - "ttl_days": ttl_days, + "ttl_days": effective_ttl_days, "run_id": run_id, } 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) logger.info( "deactivate_stale source=%s run_id=%d done: deactivated=%d " - "(ttl_days=%d, segments=%r, staleness_column=%s)", + "(ttl_days=%d эффективный, задан %d, segments=%r, staleness_column=%s)", listing_source, run_id, counters["deactivated"], + effective_ttl_days, ttl_days, segments, staleness_column, diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py new file mode 100644 index 00000000..76f34665 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -0,0 +1,334 @@ +"""Пол TTL по измеренному циклу переобхода (#2659). + +Гейт здоровья (#2710) отвечает «источник собирается?». Этот пол отвечает на второй +вопрос issue — «а достаточно ли ttl_days, чтобы молчание означало снятие?». Пока свип +возвращается к строке реже, чем раз в ttl_days, TTL меряет нашу выборку, а не жизнь +объявления, и источник при этом ЗДОРОВ — гейт молчит. + +Ключевой тест — test_effective_ttl_covers_every_proven_false_kill: он проигрывает +РЕАЛЬНЫЙ прод-случай (127 строк, снятых на возрасте 29.9..30.3 суток при TTL=30 и +доказанно вернувшихся живыми) и требует, чтобы эффективный TTL накрыл каждую. На +старом коде — без пола — эффективный TTL остаётся 30, и тест падает на всех срезах. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import deactivate_stale_avito as task_mod + +# ── Прод-замер 2026-08-09 (read-only), из-за которого пол существует ─────────── +# С деплоя гейта 06.08 TTL снял 1 028 строк, 127 уже снова активны (12.4%). +# В Екатеринбурге — единственном городе с настоящим покрытием — доля ложных 100%. +_FALSE_KILLS_BY_CITY: dict[str, tuple[int, int]] = { # срез -> (снято, снова активны) + "cian/Екатеринбург": (103, 103), + "yandex/Екатеринбург": (24, 24), + "cian/без города": (560, 0), + "yandex/без города": (341, 0), +} +# Возраст строки на момент снятия у всех 127 доказанно ложных снятий, суток. +_FALSE_KILL_AGE_MIN = 29.9 +_FALSE_KILL_AGE_MAX = 30.3 + +# Пол, который отдаёт ТОТ ЖЕ запрос на проде (percentile_disc 0.99, окно 3 суток, +# срез = срез TTL-джобы). Ключ -> (listing_source, segments, ttl_days сейчас, пол). +_PROD_FLOORS: dict[str, tuple[str, list[str] | None, int, float]] = { + "cian/vtorichka": ("cian", ["vtorichka"], 30, 34.0), + "yandex/vtorichka": ("yandex", ["vtorichka"], 30, 74.3), + "avito/все сегменты": ("avito", None, 10, 69.7), +} + +# Разрывы переобхода по срезу TTL-джобы (listing_source_snapshots, 40 суток): +# источник -> (p90, p99, ttl_days сейчас). Домклик — контрольная группа: почти +# полное суточное покрытие, TTL лежит в 4.5 раза выше хвоста, и снятие у него +# действительно означает снятие. +_REVISIT_TAIL: dict[str, tuple[float, float, int]] = { + "domklik/vtorichka": (1.9, 3.1, 14), + "cian/vtorichka": (10.9, 26.6, 30), + "yandex/vtorichka": (5.7, 43.0, 30), + "avito/vtorichka": (29.1, 42.1, 10), +} + + +# ── Фейковая сессия ─────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None: + self.rowcount = rowcount + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + +class _FakeDB: + """Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount.""" + + def __init__( + self, + *, + floor_days: float | None, + confirmations: int = 10_000, + rowcount: int = 137, + ) -> None: + self._floor = floor_days + self._confirmations = confirmations + self._rowcount = rowcount + self.executed: list[tuple[str, dict[str, Any] | None]] = [] + self.committed = False + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt.text) + self.executed.append((sql, params)) + if "percentile_disc" in sql: + return _FakeResult(scalar_value=self._floor) + if "SELECT count(*)" in sql: + return _FakeResult(scalar_value=self._confirmations) + return _FakeResult(rowcount=self._rowcount) + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: + self.rolled_back = True + + @property + def floor_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "percentile_disc" in e[0]), ("", None)) + + @property + def update_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None)) + + +def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]: + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + return task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 1, + listing_source=kwargs.pop("listing_source", "cian"), + ttl_days=kwargs.pop("ttl_days", 30), + **kwargs, + ) + + +# ── Исторический случай: 127 доказанных ложных снятий ───────────────────────── + + +def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одно из 127 доказанно ложных снятий не должно повториться. + + Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть + строго выше этого возраста на КАЖДОМ прод-срезе — иначе следующий прогон + снимет ту же строку снова. + """ + for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items(): + db = _FakeDB(floor_days=floor) + out = _run( + db, + monkeypatch, + listing_source=source, + ttl_days=ttl_days, + segments=segments, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + ) + effective = out["ttl_days_effective"] + assert effective > _FALSE_KILL_AGE_MAX, ( + f"{slice_name}: эффективный TTL {effective} не накрывает возраст ложного " + f"снятия {_FALSE_KILL_AGE_MAX} — 127 строк снимутся снова" + ) + _, update_params = db.update_query + assert update_params is not None + assert ( + update_params["ttl_days"] == effective + ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен" + + +def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше.""" + assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX + for source, _segments, ttl_days, _floor in _PROD_FLOORS.values(): + if source in ("cian", "yandex"): + assert ttl_days == 30, f"{source}: прод-TTL разошёлся с замером" + + +def test_false_kill_rate_is_total_where_coverage_is_real() -> None: + """В городе с настоящим покрытием ложны ВСЕ снятия — это и есть корень #2659.""" + for slice_name in ("cian/Екатеринбург", "yandex/Екатеринбург"): + killed, returned = _FALSE_KILLS_BY_CITY[slice_name] + assert returned == killed, f"{slice_name}: замер разошёлся" + total_killed = sum(k for k, _ in _FALSE_KILLS_BY_CITY.values()) + total_returned = sum(r for _, r in _FALSE_KILLS_BY_CITY.values()) + assert total_killed == 1028 + assert total_returned == 127 + + +def test_control_group_domklik_needs_no_floor() -> None: + """Домклик — контроль: при почти полном суточном обходе TTL с запасом выше хвоста. + + Если бы пол был нужен всем подряд, он был бы нужен и источнику со сплошным + покрытием. Ему не нужен — значит меряем именно покрытие, а не «TTL маловат». + + Разделяет источники ЗАПАС над хвостом, а не сам факт превышения: у домклика + TTL/p99 = 4.5, у остальных трёх 0.24..1.13, то есть порог сидит вплотную к + хвосту или внутри него. Именно у самого «благополучного» из трёх (cian, 1.13) + и случились 103 доказанно ложных снятия: p99 запаса не даёт, снимает 1% живых. + """ + _p90, p99, ttl = _REVISIT_TAIL["domklik/vtorichka"] + assert ttl / p99 > 4, "домклик перестал быть контрольной группой — перемерить" + for name, (_p90, p99, ttl) in _REVISIT_TAIL.items(): + if name.startswith("domklik"): + continue + assert ttl / p99 < 1.5, f"{name}: TTL отошёл от хвоста обхода, замер устарел" + + +# ── Контракт пола ───────────────────────────────────────────────────────────── + + +def test_floor_never_lowers_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол поднимает порог и только. Здоровый источник с коротким циклом не даёт + поводу снять больше, чем задано оператором.""" + db = _FakeDB(floor_days=3.1) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 30 + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 30 + + +def test_floor_is_rounded_up_not_down(monkeypatch: pytest.MonkeyPatch) -> None: + """34.0 суток разрыва -> TTL 34, а 34.2 -> 35: округление в сторону осторожности.""" + db = _FakeDB(floor_days=34.2) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert out["revisit_floor_days"] == 35 + assert out["ttl_days_effective"] == 35 + + +def test_floor_disabled_keeps_old_behaviour(monkeypatch: pytest.MonkeyPatch) -> None: + """quantile=0 -> ни одного лишнего запроса, поведение как до правки.""" + db = _FakeDB(floor_days=74.3) + out = _run(db, monkeypatch, ttl_days=30) + assert out == {"deactivated": 137} + assert len(db.executed) == 1 + assert "percentile_disc" not in db.executed[0][0] + + +def test_missing_snapshot_history_leaves_ttl_as_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """NULL (истории снимков за окно нет) -> пол не выдумывается, TTL как задан.""" + db = _FakeDB(floor_days=None) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert "revisit_floor_days" not in out + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 30 + + +def test_floor_runs_before_any_write(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол считается ДО UPDATE: снятое объявление возвращает только повторный сбор.""" + db = _FakeDB(floor_days=74.3) + _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + kinds = ["floor" if "percentile_disc" in sql else "update" for sql, _ in db.executed] + assert kinds.index("floor") < kinds.index("update") + + +def test_floor_measures_same_slice_as_update(monkeypatch: pytest.MonkeyPatch) -> None: + """Срез пола совпадает со срезом UPDATE: тот же source и те же сегменты.""" + db = _FakeDB(floor_days=74.3) + _run( + db, + monkeypatch, + listing_source="yandex", + segments=["vtorichka"], + revisit_floor_quantile=0.99, + ) + floor_sql, floor_params = db.floor_query + assert "ANY(CAST(:segments AS text[]))" in floor_sql + assert floor_params is not None + assert floor_params["segments"] == ["vtorichka"] + assert floor_params["listing_source"] == "yandex" + + +def test_floor_uses_same_staleness_column_as_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """domklik считает свежесть по scraped_at (#2204) — пол обязан мерить ту же колонку, + иначе bulk-touch по last_seen_at показал бы цикл обхода там, где сбора нет.""" + db = _FakeDB(floor_days=20.0) + _run( + db, + monkeypatch, + listing_source="domklik", + ttl_days=14, + staleness_column="scraped_at", + revisit_floor_quantile=0.99, + ) + floor_sql, _ = db.floor_query + assert "l.scraped_at" in floor_sql + assert "l.last_seen_at" not in floor_sql + + +def test_floor_rejects_invalid_staleness_column(monkeypatch: pytest.MonkeyPatch) -> None: + """Whitelist колонки работает и на пути пола — интерполяции чужого имени нет.""" + db = _FakeDB(floor_days=20.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, staleness_column="is_active", revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_health_gate_still_wins_over_floor(monkeypatch: pytest.MonkeyPatch) -> None: + """Нездоровый источник блокируется гейтом ДО того, как считается пол: + лишний тяжёлый запрос по мёртвому источнику не нужен.""" + db = _FakeDB(floor_days=74.3, confirmations=10) + out = _run( + db, + monkeypatch, + min_confirmations=500, + revisit_floor_quantile=0.99, + ) + assert out["skipped_unhealthy"] == 1 + assert db.floor_query[0] == "" + assert db.update_query[0] == "" + + +def test_floor_sql_is_psycopg_v3_safe() -> None: + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text) + assert "CAST(:revisit_quantile AS double precision)" in sql + assert "CAST(:health_window_days AS integer)" in sql + assert not re.search(r":\w+::", sql) + assert "UPDATE" not in sql.upper() + assert "DELETE" not in sql.upper() + + +def test_floor_only_counts_rows_the_sweep_actually_refound() -> None: + """Пол меряет выживших, а не «мы к ним не приходили»: свежесть обязана СДВИНУТЬСЯ + относительно предыдущего снимка, иначе разрыв нулевой и хвост схлопнется в ноль.""" + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text) + assert "l.last_seen_at > prev.last_seen_at" in sql + + +def test_default_quantile_is_high_enough_for_the_prod_case() -> None: + """Ниже 0.99 опускать нельзя без нового замера: именно на 0.99 прод-запрос даёт + 34.0 для cian/vtorichka, что накрывает возраст ложных снятий 30.3.""" + assert task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE >= 0.99 + assert _PROD_FLOORS["cian/vtorichka"][3] > _FALSE_KILL_AGE_MAX + + +def test_handler_wires_revisit_floor_from_schedule_params() -> None: + """Читаем исходник файлом: product_handlers тянет scraper_kit, которого в + юнит-окружении может не быть, а проверяем мы проводку, а не импорт.""" + handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py" + src = handlers.read_text("utf-8") + job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0] + flat = " ".join(job.split()) + assert 'params.get( "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE )' in flat + assert "revisit_floor_quantile=revisit_floor_quantile" in job From f3bcb1a25f6e52db22a34659b0ca89863aee5a01 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 17:38:40 +0000 Subject: [PATCH 104/130] =?UTF-8?q?fix(tradein/cian):=20=D0=BE=D0=B1=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=96=D0=9A=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=D0=BE=20=D0=BD=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=82=D0=BA=D0=B5,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D1=81=D0=BE=D0=B6=D0=B6=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=20=D1=83=D0=B7=D0=BB=D0=B5=20(#2767)=20(#279?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 4 +- .../app/tasks/cian_history_backfill.py | 14 +- .../app/tasks/newbuilding_enrich_backfill.py | 18 ++- .../fixtures/cian_waf_block_zhk_page.html | 1 + .../backend/tests/test_2767_cian_waf_block.py | 143 ++++++++++++++++++ .../tests/test_2767_newbuilding_parse_miss.py | 17 ++- ...scraper_kit_group_c_backfill_kit_parity.py | 8 +- .../test_scraper_kit_newbuilding_endpoint.py | 13 +- .../scraper_kit/providers/cian/newbuilding.py | 100 +++++++++--- 9 files changed, 278 insertions(+), 40 deletions(-) create mode 100644 tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html create mode 100644 tradein-mvp/backend/tests/test_2767_cian_waf_block.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 86a5539a..89d6ef8d 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -1953,7 +1953,9 @@ async def scrape_cian_newbuilding( save_newbuilding_enrichment, ) - enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig()) + enrichment = await fetch_newbuilding( + zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider() + ) if enrichment is None: raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}") diff --git a/tradein-mvp/backend/app/tasks/cian_history_backfill.py b/tradein-mvp/backend/app/tasks/cian_history_backfill.py index b6c5907b..2cb69680 100644 --- a/tradein-mvp/backend/app/tasks/cian_history_backfill.py +++ b/tradein-mvp/backend/app/tasks/cian_history_backfill.py @@ -43,7 +43,11 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings -from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig +from app.services.scraper_adapters import ( + RealMatcherAdapter, + RealProxyProvider, + RealScraperConfig, +) from app.services.scraper_settings import get_scraper_delay logger = logging.getLogger(__name__) @@ -230,7 +234,13 @@ async def backfill_cian_history( enrichment = None try: - enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig()) + # proxy_provider (#2767): тот же сожжённый env-узел бил и сюда — + # это второй вызывающий fetch_newbuilding, чинить надо оба. + enrichment = await fetch_newbuilding( + zhk_url, + config=RealScraperConfig(), + proxy_provider=RealProxyProvider(), + ) except Exception as exc: logger.warning( "cian_newbuilding fetch failed for house_id=%s url=%s: %s", diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index 93dd3113..abd51d13 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -399,9 +399,15 @@ async def backfill_newbuilding_enrichment( save_newbuilding_enrichment, ) - from app.services.scraper_adapters import RealScraperConfig + from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig scraper_config = RealScraperConfig() + # #2767: обогащение было ЕДИНСТВЕННЫМ cian-путём мимо пула прокси — весь сбор шёл + # через env-узел сайдкара, и когда Циан забанил его exit-IP, 8 суток по 25 попыток + # уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠ + # «включить пул»: реально пул задействуется, только если включён + # config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding). + proxy_provider = RealProxyProvider() result = NewbuildingEnrichBackfillResult() t0 = time.time() @@ -525,7 +531,9 @@ async def backfill_newbuilding_enrichment( # ── Fetch (network; anti-bot surface) ────────────────────────────── enrichment = None try: - enrichment = await fetch_newbuilding(zhk_url, config=scraper_config) + enrichment = await fetch_newbuilding( + zhk_url, config=scraper_config, proxy_provider=proxy_provider + ) except Exception as exc: logger.warning( "newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc @@ -535,8 +543,12 @@ async def backfill_newbuilding_enrichment( continue if enrichment is None: + # Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога + # читается дальше как факт и один раз уже увела диагноз не туда. Причина + # печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers). logger.warning( - "newbuilding fetch returned None house_id=%s url=%s (captcha / parse miss?)", + "newbuilding fetch returned None house_id=%s url=%s — причина в строке " + "'initialState extraction failed' выше", house_id, zhk_url, ) diff --git a/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html new file mode 100644 index 00000000..3fd69b35 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html @@ -0,0 +1 @@ +Ошибка - ЦианISIDPlhwqgqcowpM8uZOWWzawmWX3JbKNOWV5y8jbBk1QTMxXSbNTCYzv2eY56%2BzgvWctsm1nNNBInjiTIE4wTKyIXqNmtYlOzmhaTK60Tn8LgFw465ooSTZRlZbWNAVRqsSkSbwX3cLXr%2BXama%2BvH5PpteyqjVoMEnZZ9fy7U9Mq6lNhNqPwTg6YASdghUaijPGTLJGRj1xv2zyp3nGfDDlmBEufGFsQSmWpaquZAFvWbMSIVagrgmWV%2FpwZY%2B05W7zk%2B48X94xHfjeo42qibiytt3w1NTImtWX6RAUPZOEWRMJX%2FDAhDnp6PpcNPWJMlIzO54LIEgTLTbPMeT8Zcz8bbz8bbz8bbz8bcxqGIlysW6SCSEHnI3OmWtyI1qNpU%2BaVl4fL4yPXGUHMlzpFO8g7x3nL5nBgMCqbNNM489eVZ8CQV07DTtFVFNVZlZbprTPdHkXK8dpDGUHO2YoUriDzZ%2FPcifCVNlgCap0b6RzwJbvsziCR09wO2bk%2B2Fh0BtH1djPV2M9XYz1djPV2MtsmyQM5f7xfaMjZdAbCdhzBtLjB7V9hsvkNjwjKUT8C9Zw3rBwyJ6wsNWNoze1g2nkHtUNoereRt7INYvs9XYwnf%2FAM6pU5R2c0UDE8gAHwP%2FAIr%2FxAA4EQABAwEFBAgFAgcBAAAAAAABAAIRECExQVGRAxIgYQQiUHGBobHRMDKSwdJA8BMjM0JgguHx%2F9oACAEDAQk%2FAPgbQSMJtW01sTgRy7OMNF5z5BWoJxgYK%2FEdmXus1oN5y2e6cx%2F2aXO7MuF6vCvx76f22I%2FK5vjJ7MxCwVrcimhs440F8Hs0dceYqLMEI7OHeEOqELB2g2Ozf%2FV8hN2QlGQUBC3UIPDu7qIgRhwRu4LdjQ13Ykwt0jzrgmhrcAb%2FABujuW7GivoC52QtPeYuHNbuyH1fiulR3NP5KH8gI%2B5Tf4buZ9wKxHNbqvqBGMrdKEOx4jACsYLh9zQ9QrIff4vOuayqOoNZ9qZ0vRlxvKEqE2EIcLjzXzM%2BDnw3L%2BmDfmeAzFnxedc1lQwSChAGFM6P3T3LbDQo2Zoq0H1WC%2BY%2FBz4f9jlyq0gH4%2FOuayV6Mk%2FuPBCwIRLQVnQwBem7ozdf4DEeK6S7ws9Cukv%2Bo%2B66Q8xhvO96Z1dDsDetsNFtm6LbN0Tt5wvMRTPgM7S7uqIi6ULCjIFLyhB4mzPC2RbwDqAWnxQIGHMZoWBXDzRuaAhNqvRkNN2EhCSiGhbb96LbQB97BhmihZQyBI0KEpiYmIQUEIqf5jssOavNBZhKuoIeL7L62u9RwCQaGokGhoJFLWnDgzKNGx3Zmhmgkug2YQZBPIkRQ2UAA5c6PKedU86oyeC%2BLFa5xv8Aag6oVgHBdiOeYpejaQOHaBhzIn1BXSR9I%2FFdJH0j8V0mQCLA0W%2BQptA05kT6grpE8g0ew4L65mtxV62IIzk%2ByY1nMkzpulHecbzn7dws4OXHlUWLrNOcSPRCLOESCpJm0EjyuQ63C2R3hM9EzzCZ5hM8wmeiEFBYpkjvCZ5j3QgCo3gbcL1s9SPdC1XH9yjvDIkSPRMKYVbOATZaeYTIH21oJB7ls%2FT3Qg1E6LZye8e6EH9C4hPOpTzqU86lPOpTzqUZKyo4jxTifHgcQnnWpTynnWjiE4mjiE86ngMJ51P6hknkmGf8M%2F%2FZ" alt="logo">

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

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

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

  • IP-адрес: 46.8.110.92
  • ID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366
  • Код страницы: cian_waf_block
" +) + + @pytest.mark.asyncio async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None: """Блок → `report_ban`, пока lease ещё жив. @@ -123,6 +136,35 @@ async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, 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 → пул. diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index a2d19d77..89034166 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -227,20 +227,27 @@ async def fetch_newbuilding( # прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). # При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP. # Понадобится дешевле — поднимать фетчер в вызывающий цикл и передавать сюда. - async with browser: - html = await browser.fetch(zhk_url) - # Блок распознаётся ПОКА lease жив: после выхода из `async with` узел уже - # отпущен и `report_ban` стал бы no-op. Пул после этого не выдаёт узел под - # source='cian' (scrape_proxy_source_bans), и следующий дом идёт с другого IP. - 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; перепроверено живьём 2026-08-09 — разметка та же). mfe = "newbuilding-card-desktop-frontend" - nb_state = extract_state(html, mfe=mfe, key="initialState") + async with browser: + html = await browser.fetch(zhk_url) + # Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода + # из `async with` узел уже отпущен и `report_ban` стал бы no-op. + nb_state = extract_state(html, mfe=mfe, key="initialState") + # Условие бана — «состояния НЕТ и есть подпись защиты», а не одна подпись. + # Живая регрессия 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}") + if nb_state is None: logger.warning( "Cian newbuilding %s: initialState extraction failed — %s", From 7cd8c63b895324b9215d2d7addec96c499bd968f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 18:15:55 +0000 Subject: [PATCH 107/130] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D0=B7?= =?UTF-8?q?=D0=B5=D0=BB,=20=D0=B7=D0=B0=D1=80=D0=B5=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B7=D0=B0=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC,=20=D0=B4=D0=BE=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=20(#2800=20A)=20(#2802)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/proxy_pool.py | 23 +++++--- ...scrape_proxy_domclick_affinity_release.sql | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index ed40dc63..5e2cb9c5 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -228,14 +228,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах чужой affinity (#2600). - Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см. - 173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1), - намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого - одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex, - domclick останется без прокси вообще — хуже, чем голодание исходного источника, - которое фикс призван устранить. Кандидат участвует в fallback, только если его - affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) — - т.е. выдача не обнулит доступность выделенной affinity целиком. + Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если + fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже, + чем голодание исходного источника, которое фикс призван устранить. Кандидат + участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ + enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной + affinity целиком. + + Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql — + единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR + банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до + рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а + Авито/Яндекс через него работают — резервация держала узел за источником, которому + он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде + сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и + следующий выделенный узел должен получить её сразу, а не после повторного разбора. ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у (scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито, diff --git a/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql new file mode 100644 index 00000000..b1f63939 --- /dev/null +++ b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql @@ -0,0 +1,52 @@ +-- 253_scrape_proxy_domclick_affinity_release.sql +-- Снять с узла резервацию provider_affinity='domclick' (#2800). +-- +-- WHY (замер, не рассуждение — живая проба 09.08.2026, тракт сайдкар+camoufox, +-- POST /fetch на robots.txt рабочего хоста каждой площадки): +-- +-- узел | affinity | avito | ekb.cian.ru | realty.yandex.ru | bff-search-web.domclick.ru +-- -----+----------+-------+--------------------+------------------+--------------------------- +-- 1 | domclick | 200 | 200 «Ошибка — Циан»| 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY +-- 9 | any | 200 | 200 | 200 | 200 +-- 10 | any | 200 | 200 | 200 | 200 +-- 11 | any | 200 | 200 | 200 | 200 +-- +-- Узел, закреплённый 173-й миграцией СПЕЦИАЛЬНО за Домкликом, до рабочего хоста +-- Домклика не доходит вообще (NS_ERROR_PROXY_BAD_GATEWAY на bff-search-web — +-- именно туда ходит боевой сбор, см. providers/domclick/serp.py::_BFF_BASE), при +-- этом Авито и Яндекс через него отвечают штатно. Резервация даёт ровно обратный +-- эффект задуманному: единственный источник, которому узел ГОДЕН НЕ БЫЛ, держал его +-- за собой, а два источника, которым он годен, его не видели — +-- acquire('avito'|'yandex') отбирает по provider_affinity IN (source,'any'), а +-- fallback этот узел не берёт (защита последнего узла выделенной affinity). +-- +-- 'any', а НЕ enabled=false: узел жив для двух площадок из четырёх, выключать его +-- целиком — терять четверть и без того дефицитного пула (#2638). +-- +-- WHAT: +-- provider_affinity='domclick' → 'any' для узлов, у которых affinity именно такая. +-- CHECK-констрейнт (173) не трогаем: значение 'domclick' остаётся допустимым, если +-- в пуле появится узел, который до Домклика реально доходит. +-- +-- ЧТО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ (граница честная): +-- Она НЕ чинит Домклик. acquire('domclick') и до неё видел все четыре узла +-- (affinity IN ('domclick','any')), т.е. шанс вытянуть узел 1 и потратить первый +-- бакет впустую был и остаётся 1/4 — закрывает это проба по паре «узел × источник» +-- (#2800 часть B), а не смена affinity. Здесь снимается только резервация. +-- +-- IDEMPOTENCY / SAFETY: +-- Один UPDATE в транзакции; повторный прогон не находит строк (no-op) — auto-apply +-- strict на деплое это требует. Блокирующего DDL нет (см. +-- scripts/check-migration-lock-timeout.py: правило про ALTER/DROP/CREATE INDEX), +-- UPDATE берёт row-lock на единичные строки. +-- +-- Dependencies: 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql + +BEGIN; + +UPDATE scrape_proxies +SET provider_affinity = 'any', + updated_at = now() +WHERE provider_affinity = 'domclick'; + +COMMIT; From 08bb9d6549b106aac5b8b4927ba512b01f86f8ec Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 18:28:37 +0000 Subject: [PATCH 108/130] =?UTF-8?q?fix(tradein/proxy):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=20=D1=81=D0=BF=D1=80=D0=B0=D1=88=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=83=D1=8E=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D1=83,=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82=20=D0=BF=D0=B8=D1=88=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=BF=D0=B0=D1=80=D1=83=20?= =?UTF-8?q?=C2=AB=D1=83=D0=B7=D0=B5=D0=BB=20=C3=97=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=C2=BB=20(#2800=20B)=20(#2803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/proxy_pool.py | 281 +++++++++++-- .../backend/tests/services/test_proxy_pool.py | 8 +- .../backend/tests/test_2723_browser_probe.py | 18 +- .../tests/test_2800_per_source_probe.py | 382 ++++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 79 +++- 5 files changed, 717 insertions(+), 51 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2800_per_source_probe.py diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 5e2cb9c5..bf1f26e7 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08): «Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже. +Проба на ПАРУ «узел × источник» (#2800, продолжение #2723): + - #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом + для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв» + вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500 + NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и + свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен». + - Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может + достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки + (apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а + `bff-search-web.domclick.ru`, куда ходит сбор, — 500). + - Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой + сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый + отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки. + Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt + площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать + дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»). + - Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение + «браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО + креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла. + Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками + вернули то самое схлопывание диагнозов. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -125,6 +147,7 @@ __all__ = [ "mark_banned", "mark_browser_health", "mark_health", + "mark_source_probe", "reap_stale_leases", "release", "run_proxy_healthcheck", @@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360 # намеренно не обновляется (см. mark_browser_health). BROWSER_UNFIT_THRESHOLD = 2 +# ── проба на пару «узел × источник» (#2800) ────────────────────────────────── +# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт): +# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито; +# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть +# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch; +# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов +# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at). +# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при +# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было. +# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и +# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором +# acquire снова выдаёт узел, не спросив. +# +# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её +# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная +# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка +# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка +# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре. +_PROBE_BAN_REASON = "probe:browser" + # deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для # mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что # в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не @@ -682,9 +725,14 @@ def mark_browser_health( 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) -> None: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по + умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы — + _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому + clear_source_bans умеет фильтровать по ней (`only_reason`). + Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация — транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно @@ -820,7 +868,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: { "proxy_id": proxy_id, "source": source, - "reason": f"banned:{source}", + "reason": reason or f"banned:{source}", "base_hours": SOURCE_BAN_BASE_HOURS, "max_hours": SOURCE_BAN_MAX_HOURS, "max_fails": MAX_CONSECUTIVE_FAILS, @@ -866,7 +914,14 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: ) -def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int: +def clear_source_bans( + db: Session, + proxy_id: int, + *, + source: str | None = None, + reason: str, + only_reason: str | None = None, +) -> int: """Снять баны узла по источникам (#2600 п.2). Returns число снятых строк. ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через @@ -889,6 +944,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, SOURCE_BAN_BASE_HOURS. `reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»). + + `only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые + написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое + свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан, + распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация + IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной + целиком. None — снимать всё, как и раньше. """ rows = db.execute( text( @@ -896,10 +958,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, DELETE FROM scrape_proxy_source_bans WHERE proxy_id = CAST(:proxy_id AS bigint) AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text)) + AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text)) RETURNING source """ ), - {"proxy_id": proxy_id, "source": source}, + {"proxy_id": proxy_id, "source": source, "only_reason": only_reason}, ).fetchall() db.commit() if rows: @@ -913,6 +976,75 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, return len(rows) +def mark_source_probe( + db: Session, + proxy_id: int, + *, + source: str, + ok: bool, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800). + + Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или + не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает + `acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим. + + КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница + другая — здесь судится ПАРА, а не узел): + - "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored". + Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз); + - "proxy" — через этот узел до площадки не доходит транспорт + (NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары; + - "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку + (200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары. + Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для + ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя. + + Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный + боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить + дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. + + Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего + своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не + пишется (голодание хуже работы через плохой узел). + + Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored". + """ + 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, + ) + mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) + return "banned" + + def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: """Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release). @@ -976,8 +1108,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No return False, None, None, "other" -async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str: - """Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health. +def _probe_sources_for(affinity: str) -> list[str]: + """Источники, которым узел с такой affinity МОЖЕТ достаться (#2800). + + Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`. + Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику, + которой никто не воспользуется. + + ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих + свободных нет) — такая пара останется без вердикта и решится как раньше, по факту + прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же + только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос + бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4. + """ + from scraper_kit.browser_fetcher import PROBE_SOURCES + + return [s for s in PROBE_SOURCES if affinity in (s, "any")] + + +async def _run_pair_probes( + db: Session, proxy_id: int, url: str, kind: str, affinity: str +) -> tuple[str, dict[str, int]]: + """Крест «этот узел × каждая его площадка» + запись вердиктов (#2800). + + Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам). + + Два уровня вердикта, и они не пересекаются: + - ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке + отдельно, это то, что читает `acquire(source)`; + - УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по + итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел + работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки + узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов + появилось бы новое. Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не @@ -985,18 +1148,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 - try: - ok, fail_kind, detail = await probe_proxy_via_browser( - _settings.browser_http_endpoint, url, proxy_kind=kind + counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0} + fail_kinds: list[str] = [] + any_ok = False + last_detail = "" + + for source in _probe_sources_for(affinity): + try: + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + if not ok and fail_kind == "proxy": + # Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает + # флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор + # идёт по уже поднятому браузеру с тем же прокси — секунды, и только + # на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью + # (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания. + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + except Exception: + logger.warning( + "proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан", + proxy_id, + source, + exc_info=True, + ) + continue + + counters["pair_checked"] += 1 + last_detail = detail + if ok: + any_ok = True + else: + fail_kinds.append(fail_kind or "other") + outcome = mark_source_probe( + db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail ) - except Exception: - logger.warning( - "proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан", - proxy_id, - exc_info=True, - ) - return "ignored" - return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail) + if outcome == "banned": + counters["pair_banned"] += 1 + elif outcome == "cleared": + counters["pair_cleared"] += 1 + + if counters["pair_checked"] == 0: + return "ignored", counters # крест не состоялся — узел не судим + + if any_ok: + return mark_browser_health(db, proxy_id, True, detail=last_detail), counters + # Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ — + # транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле». + node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0] + return ( + mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail), + counters, + ) def _mask(url: str) -> str: @@ -1032,18 +1237,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад (см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count). - БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером - дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же - тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на - robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и - никогда не смешивается с consecutive_fails/enabled. Гейт — settings. - use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба - измеряла бы то, чем никто не пользуется. + БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не + проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ + САЙДКАР — по одной навигации на каждую площадку, которую этот узел может + обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси, + потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в + scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные + browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт — + settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и + проба измеряла бы то, чем никто не пользуется. Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters {reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok, - browser_unfit, browser_refit}. + browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}. """ reaped = reap_stale_leases(db) @@ -1051,7 +1258,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind, enabled, disabled_reason, + SELECT id, url, kind, enabled, disabled_reason, provider_affinity, (browser_check_at IS NULL OR browser_check_at < now() - make_interval( mins => CAST(:browser_probe_minutes AS integer) @@ -1082,6 +1289,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: browser_ok = 0 browser_unfit = 0 browser_refit = 0 + pair_checked = 0 + pair_banned = 0 + pair_cleared = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) @@ -1111,8 +1321,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: # вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет # не тот узел», из-за которого #2723 и появилась. 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 + pair_checked += pair_counters["pair_checked"] + pair_banned += pair_counters["pair_banned"] + pair_cleared += pair_counters["pair_cleared"] if outcome in ("ok", "refit"): browser_ok += 1 if outcome == "refit": @@ -1142,7 +1357,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: logger.info( "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d " - "bans_purged=%d 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, checked, ok_count, @@ -1153,6 +1369,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: browser_ok, browser_unfit, browser_refit, + pair_checked, + pair_banned, + pair_cleared, ) return { "reaped": reaped, @@ -1167,4 +1386,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: "browser_ok": browser_ok, "browser_unfit": browser_unfit, "browser_refit": browser_refit, + # Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и + # pair_banned=2 одновременно — это не противоречие, а точный диагноз + # «браузер через узел работает, но две площадки его не пускают». + "pair_checked": pair_checked, + "pair_banned": pair_banned, + "pair_cleared": pair_cleared, } diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index a62f9886..bd360aed 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -397,12 +397,18 @@ class FakeSession: ) 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 = [ b for b in self.bans if b["proxy_id"] == p["proxy_id"] 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] return _FakeResult([{"source": b["source"]} for b in cleared]) diff --git a/tradein-mvp/backend/tests/test_2723_browser_probe.py b/tradein-mvp/backend/tests/test_2723_browser_probe.py index 0bfb8dae..f44e0181 100644 --- a/tradein-mvp/backend/tests/test_2723_browser_probe.py +++ b/tradein-mvp/backend/tests/test_2723_browser_probe.py @@ -260,27 +260,35 @@ async def test_healthcheck_marks_unfit_when_http_green_browser_red( row = db._by_id(1) assert row["browser_unfit_since"] is not None 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( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.""" + """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет. + + Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на + число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой + по-прежнему один на BROWSER_PROBE_MINUTES. + """ calls: list[str] = [] _patch_probes(monkeypatch, calls=calls) db = FakeSession([_proxy(1)]) 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] - assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox" + assert len(calls) == 4, "браузерная проба обязана идти реже ipify — она стоит camoufox" db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta( minutes=BROWSER_PROBE_MINUTES + 1 ) 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( diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py new file mode 100644 index 00000000..7eca4afd --- /dev/null +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -0,0 +1,382 @@ +"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ. + +Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь — +верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный +ответ означал «узел годен для Авито», а читался как «узел годен вообще». + +Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация): + + узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru + -----+----------+-------+---------------------+--------------+--------------------------- + 1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY + 10 | any | 200 | 200 | 200 | 200 + +Что сторожится (каждый тест ниже падает на коде до фикса): + + 1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для + Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')` + выдавал его как ни в чём не бывало. + 2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен + (`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной). + 3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех. + 4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и + забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что + «дешёвая ipify стирает браузерный вердикт» в #2723). + 5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой + вердикт #2723 сохранён). + 6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс). + 7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +import scraper_kit.browser_fetcher as bf + +from app.services import proxy_pool +from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release +from tests.services.test_proxy_pool import FakeSession, _proxy + +# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным. +_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = { + ("http://u:p@h1:8080", "domclick"): ( + False, + "proxy", + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + ), +} + + +def _patch_probes( + monkeypatch: pytest.MonkeyPatch, + matrix: dict[tuple[str, str], tuple[bool, str | None, str]], + calls: list[tuple[str, str]] | None = None, + *, + default: tuple[bool, str | None, str] = (True, None, "html_len=16477"), +) -> None: + """ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник).""" + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _fake_browser( + endpoint: str, proxy_url: str, **kw: Any + ) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + if calls is not None: + calls.append((proxy_url, source)) + return matrix.get((proxy_url, source), default) + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) + + +# ── 1. главное: вердикт разведён по источникам ─────────────────────────────── + + +async def test_node_dead_for_domclick_is_not_issued_to_domclick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём. + + Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при + `browser_fail_streak=0` и свежей пробе. + """ + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + # Домклику достаётся только исправный узел… + first = acquire(db, "domclick") # type: ignore[arg-type] + assert first is not None and first.id == 2 + + # …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить + # заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой + # заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0. + assert acquire(db, "domclick") is None, ( # type: ignore[arg-type] + "Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY" + ) + + # Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла. + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None and lease.id == 1 + + ban = db._ban(1, "domclick") + assert ban is not None + assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба" + assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None + assert counters["pair_banned"] == 1 + + +async def test_one_dead_platform_does_not_stain_the_node_globally( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает.""" + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + row = db._by_id(1) + assert row["browser_unfit_since"] is None + assert row["browser_fail_streak"] == 0 + assert row["enabled"] is True and row["consecutive_fails"] == 0 + + +async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел).""" + dead_everywhere = { + ("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable") + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, dead_everywhere) + db = FakeSession([_proxy(1), _proxy(2)]) + + for _ in range(BROWSER_UNFIT_THRESHOLD): + db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._by_id(1)["browser_unfit_since"] is not None + assert db._by_id(2)["browser_unfit_since"] is None + + +# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ──────────────────────────────── + + +@pytest.mark.parametrize( + ("source", "must_contain"), + [ + ("avito", "www.avito.ru"), + ("cian", "ekb.cian.ru"), + ("yandex", "realty.yandex.ru"), + # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, + # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. + ("domclick", "bff-search-web.domclick.ru"), + ], +) +async def test_probe_asks_the_working_host_of_each_source( + monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str +) -> None: + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = '{"html": "
User-agent: *
"}' + + @staticmethod + def json() -> dict[str, str]: + return {"html": "
User-agent: *
"} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["payload"] = json + return _Resp() + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, _fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source=source + ) + + assert ok is True + assert must_contain in seen["payload"]["url"] + assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет + # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии. + assert seen["payload"]["source"] == "generic" + + +async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы.""" + stub = "Ошибка - Циан…" + + class _Resp: + status_code = 200 + text = "{}" + + @staticmethod + def json() -> dict[str, str]: + return {"html": stub} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + return _Resp() + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source="cian" + ) + + assert ok is False + # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. + assert fail_kind == "page" + assert "not robots.txt" in detail + + +async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя.""" + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "cian") is not None + assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём + + +# ── 4. проба снимает только свою строку ────────────────────────────────────── + + +async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором. + + robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить + дорогой вердикт значило бы повторить #2723 на паре. + """ + later = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": later, + "ban_count": 1, + "reason": "banned:avito", # распознан боевым сбором (капча/QRATOR) + }, + { + "proxy_id": 1, + "source": "cian", + "banned_until": later, + "ban_count": 1, + "reason": "probe:browser", # прошлый вердикт самой пробы + }, + ], + ) + _patch_probes(monkeypatch, {}) # все площадки отвечают + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "avito") is not None, "чужой бан проба снимать не имеет права" + assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять" + assert counters["pair_cleared"] == 1 + + +# ── 5-6. чужие отказы ──────────────────────────────────────────────────────── + + +async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + """Лежащий сайдкар не должен забанить разом все пары (#2686-класс).""" + down = { + (f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError") + for pid in (1, 2) + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, down) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db.bans == [] + assert db._by_id(1)["browser_unfit_since"] is None + + +# ── 7. цена такта ──────────────────────────────────────────────────────────── + + +async def test_probe_cost_is_nodes_times_servable_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Крест ограничен источниками, которым узел вообще может достаться. + + Диагностика не должна превращаться в нагрузку: узел с выделенной affinity + спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника + = 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на + площадку в сутки против ~1000 боевых /fetch. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, {}, calls) + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [ + "avito", + "cian", + "domclick", + "yandex", + ] + assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"] + assert len(calls) == 5 + + +async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None: + """Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки. + + Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из + теста выше не меняется, пока всё зелено. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, _LIVE_MATRIX, calls) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert calls.count(("http://u:p@h1:8080", "domclick")) == 2 + assert calls.count(("http://u:p@h1:8080", "avito")) == 1 + + +async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """Один провал, второй заход зелёный → бан пары не пишется.""" + attempts: dict[str, int] = {} + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + key = f"{proxy_url}|{source}" + attempts[key] = attempts.get(key, 0) + 1 + if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1: + return False, "proxy", "503 browser unavailable" + return True, None, "html_len=150" + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db._ban(1, "domclick") is None + lease = acquire(db, "domclick") # type: ignore[arg-type] + assert lease is not None + release(db, lease.id) # type: ignore[arg-type] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 640f7492..034de30f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -37,22 +37,50 @@ _RETRY_SLEEP_S: float = 1.0 _HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут # ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ───────────────────────────────── -# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает: +# Адрес пробы. Требования к нему ровно три, и robots.txt им отвечает: # 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая # навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may # be down)») рождались на launch'е camoufox с прокси — проба обязана его делать; -# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие -# и маршрут до её edge, а не до нейтрального хоста; -# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который +# 2) та же площадка, что реально отказывает: TLS-рукопожатие и маршрут до ЕЁ edge, +# а не до нейтрального хоста; +# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл, который # автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка. -# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16 -# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08). -_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt" -# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу -# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок -# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот -# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые -# развёртки его не используют. +# +# АДРЕС НА КАЖДЫЙ ИСТОЧНИК, а не один зашитый (#2800). До этого проба всех узлов +# ходила на Авито, и её зелёный ответ читался как «узел годен», хотя означал +# «годен для Авито». Замер на проде 09.08.2026 показал ровно ту цену: узел id=1 +# отдавал 200 на Авито/Яндексе и 500 NS_ERROR_PROXY_BAD_GATEWAY на Домклике, имея +# при этом browser_fail_streak=0 и свежую пробу. +# +# ХОСТ — РАБОЧИЙ, А НЕ 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" # Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с # замерено на проде вместе с релончем, плюс запас на медленный узел. @@ -126,8 +154,10 @@ def classify_browser_probe(status: int | None, detail: str) -> str: browser_fail_streak. - "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут, 503 «no proxy configured», прочие 5xx). Узел не виноват. - - "page" — тракт сработал, но ответ не похож на страницу (пустое тело). - Узел не виноват; повод посмотреть на площадку, не на пул. + - "page" — тракт сработал, но ответ не похож на запрошенный ресурс (пустое + тело либо 200 со страницей-заглушкой вместо robots.txt, #2800). + Браузерный тракт узла исправен — не годится ПАРА «узел × + площадка»: этому exit-IP площадка ресурс не отдала. """ if status is None: return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера @@ -143,10 +173,11 @@ async def probe_proxy_via_browser( proxy_url: str, *, proxy_kind: str = "http", - url: str = _PROXY_PROBE_URL, + source: str = "avito", + url: str | None = None, timeout_s: float = _PROXY_PROBE_TIMEOUT_S, ) -> tuple[bool, str | None, str]: - """Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси. + """Проверить ПАРУ «узел × площадка» тем же трактом, что и работа: сайдкар → camoufox → прокси. Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и вредна — health-checker проверяет узлы, в том числе арендованные, и не должен @@ -156,13 +187,18 @@ async def probe_proxy_via_browser( делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная нагрузка на неё, ради которой проба и затевалась бы наоборот. + `source` выбирает АДРЕС пробы (`_PROBE_URLS`, #2800) — рабочий хост именно этой + площадки. Прежняя сигнатура спрашивала только Авито, и её зелёный ответ означал + «узел годен для Авито», а читался как «узел годен». `url` (явный адрес) остаётся + для тестов и разовых проверок и перекрывает `source`. + Returns: (ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind — "proxy" / "sidecar" / "page" (см. classify_browser_probe), detail — обрезанный текст для лога. """ payload: dict[str, object] = { - "url": url, + "url": url or _PROBE_URLS.get(source, _PROXY_PROBE_URL), "source": _PROXY_PROBE_SOURCE, "proxy": proxy_url, "proxy_kind": proxy_kind, @@ -184,6 +220,15 @@ async def probe_proxy_via_browser( html = "" if not html: return False, classify_browser_probe(resp.status_code, detail), "empty html" + if _PROBE_CONTENT_MARKER not in html: + # 200 и непустое тело, но это не robots.txt — площадка отдала заглушку + # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»). + # Тракт узла исправен, негодна пара — отсюда "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)}" From 9cd6db023b160e30be69482235a155a7566f01b1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 18:41:02 +0000 Subject: [PATCH 109/130] =?UTF-8?q?fix(tradein/cian):=20=D0=A6=D0=B8=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=20MFE=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=96=D0=9A=20=E2=80=94=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BE=D0=B1=D0=B0=20=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=20(#2767)=20(#2804)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/tests/test_2767_cian_waf_block.py | 46 +++++++++++++++++ .../tests/test_2767_newbuilding_parse_miss.py | 8 ++- .../scraper_kit/providers/cian/newbuilding.py | 51 ++++++++++++++++--- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/tests/test_2767_cian_waf_block.py b/tradein-mvp/backend/tests/test_2767_cian_waf_block.py index 660db0f7..43d793df 100644 --- a/tradein-mvp/backend/tests/test_2767_cian_waf_block.py +++ b/tradein-mvp/backend/tests/test_2767_cian_waf_block.py @@ -183,3 +183,49 @@ async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) ) factory.assert_called_once_with(config, "cian", proxy_provider=provider) + + +# ── новый фронт Циана: тот же ключ под другим именем MFE ────────────────────── + +# Форма живой страницы zhk-kosmos-ekb-i.cian.ru (2026-08-09): состояние лежит под +# 'newbuilding-card-desktop-fichering-frontend', СТАРОГО имени на странице нет вовсе. +_FICHERING_CARD = ( + "" +) + + +@pytest.mark.asyncio +async def test_state_is_found_under_the_renamed_fichering_mfe(monkeypatch) -> None: + """ЖК на новом фронте Циана разбирается — вместе с графиком цен. + + Именно из-за этого имени `houses_price_dynamics` стояла с 2026-07-26: страница + приходила целиком, состояние в ней было, но под другим MFE — и отказ выглядел как + «разметка изменилась», хотя форма состояния та же. + """ + fetcher = _spy_fetcher(_FICHERING_CARD) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-kosmos-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + assert result is not None + assert result.cian_internal_house_id == 3235089 + assert result.name == "Космос" + # График — тот самый, ради которого задача и существует. + assert [p["month_date"] for p in result.realty_valuation_chart] == [ + "2026-06-01", + "2026-07-01", + ] + assert [p["price_per_sqm"] for p in result.realty_valuation_chart] == [4100000.0, 4250000.0] + fetcher.report_ban.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py index 28b16f56..81056835 100644 --- a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -53,9 +53,13 @@ def test_describe_parse_miss_reports_size_and_no_markers() -> None: def test_describe_parse_miss_names_found_markers() -> None: - """Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного.""" + """Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного. + + Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть + на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после + того, как забанила здоровые узлы (#2767). + """ described = _describe_parse_miss("
Вы не робот?
") - assert "captcha" in described assert "вы не робот" in described assert "html_len=" in described diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index 89034166..4211f73f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -1,7 +1,9 @@ """Cian.ru newbuilding (ЖК) catalog page scraper. URL: https://zhk---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: - realtyValuation: 7-month price chart (data.priceDynamics.chart.data.{labels,values}) @@ -82,8 +84,13 @@ _CATPH_NEWBUILDING_SERP = ( # то есть по размеру НЕОТЛИЧИМА от целой карточки — «~1 МБ значит разметка» не работает, # различает только маркер. Молчание списка читалось как «защита проверена и не нашлась»; # на деле список просто не знал этой подписи (см. capture в #2767). +# Голой подстроки "captcha" здесь НЕТ намеренно (живая проба 2026-08-09): ЛЮБАЯ здоровая +# карточка ЖК грузит скрипт SmartCaptcha, поэтому "captcha" на ней есть всегда. Стена +# капчи опознаётся по своему заголовку «Вы не робот?» — он в списке ниже и на проде +# срабатывал вместе с "captcha" на всех 15-килобайтных стенах, так что снятие ложного +# маркера ничего не теряет. Пока он тут был, отказ разбора большой (682-886 КБ) страницы +# сопровождался баном ЗДОРОВОГО узла с причиной "captcha". _ANTIBOT_MARKERS = ( - "captcha", # покрывает и recaptcha, и smartcaptcha "qrator", "ddos-guard", "cf-chl", # Cloudflare challenge @@ -109,6 +116,37 @@ def _describe_parse_miss(html: str) -> str: 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. @@ -227,15 +265,11 @@ async def fetch_newbuilding( # прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). # При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP. # Понадобится дешевле — поднимать фетчер в вызывающий цикл и передавать сюда. - # Cian ЖК-карточка: MFE 'newbuilding-card-desktop-frontend', key 'initialState' - # (verified live 2026-06-15; перепроверено живьём 2026-08-09 — разметка та же). - mfe = "newbuilding-card-desktop-frontend" - async with browser: html = await browser.fetch(zhk_url) # Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода # из `async with` узел уже отпущен и `report_ban` стал бы no-op. - nb_state = extract_state(html, mfe=mfe, key="initialState") + nb_state, mfe = _extract_nb_state(html) # Условие бана — «состояния НЕТ и есть подпись защиты», а не одна подпись. # Живая регрессия 2026-08-09 (поймана на проде через 17 секунд после деплоя): # маркер по одному лишь тексту забанил два ЗДОРОВЫХ узла на странице, которая @@ -250,8 +284,9 @@ async def fetch_newbuilding( if nb_state is None: logger.warning( - "Cian newbuilding %s: initialState extraction failed — %s", + "Cian newbuilding %s: initialState extraction failed (искали в MFE %s) — %s", zhk_url, + "/".join(_NEWBUILDING_MFES), _describe_parse_miss(html), ) return None From 27e199e3700bb6d479a454e9c73c3c93ed1c8cc0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 20:13:10 +0000 Subject: [PATCH 110/130] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D0=BF?= =?UTF-8?q?=D0=B0=D0=B2=D1=88=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D1=81=D0=B2=D0=B0=D0=B8=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B5=D0=B1=D0=B5=20=D0=B1=D0=B0=D0=BD=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20(#2800)=20(#2805)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/proxy_pool.py | 109 +++++++++++++++--- .../backend/tests/services/test_proxy_pool.py | 17 +++ .../tests/test_2800_per_source_probe.py | 76 +++++++++++- 3 files changed, 184 insertions(+), 18 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index bf1f26e7..8ed2176e 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -725,14 +725,43 @@ def mark_browser_health( return "fail" -def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> None: +def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары + принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего + узла) | "missing" (нет такого proxy_id). + `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы — _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому clear_source_bans умеет фильтровать по ней (`only_reason`). + ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026: + пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через + ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр + «снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ: + присвоенная строка становится «своей», и следующая успешная проба снимает ею бан, + который поставил боевой сбор по настоящему отказу площадки. Плюс теряется + происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба + не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count + начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых + пары с 6 ч до 12 ч). + + Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет), + ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`). + Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно» + только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен + УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего. + + АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее + (площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится + ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный + robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально + и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного); + обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об + эскалации боевых банов пары. + Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация — транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно @@ -791,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает (падает как обычно) — caller решает, ловить или нет. """ + # Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё + # (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ"). + live_reason = f"banned:{source}" + effective_reason = reason or live_reason # Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг # "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции. db.execute( @@ -862,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = ) AS integer)), reason = CAST(:reason AS text), updated_at = now() + -- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет), + -- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее + -- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803). + WHERE scrape_proxy_source_bans.banned_until <= now() + OR scrape_proxy_source_bans.reason = CAST(:reason AS text) + OR CAST(:reason AS text) = CAST(:live_reason AS text) RETURNING ban_count, banned_until """ ), { "proxy_id": proxy_id, "source": source, - "reason": reason or f"banned:{source}", + "reason": effective_reason, + "live_reason": live_reason, "base_hours": SOURCE_BAN_BASE_HOURS, "max_hours": SOURCE_BAN_MAX_HOURS, "max_fails": MAX_CONSECUTIVE_FAILS, @@ -888,10 +928,40 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = row["banned_until"], row["ban_count"], ) - return + return "banned" + + # 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита + # последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога + # (на решение уже не влияет), но диагноз должен называть то, что произошло. + holder = ( + db.execute( + text( + """ + SELECT reason, banned_until + FROM scrape_proxy_source_bans + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND source = CAST(:source AS text) + AND banned_until > now() + """ + ), + {"proxy_id": proxy_id, "source": source}, + ) + .mappings() + .fetchone() + ) + if holder is not None and holder["reason"] != effective_reason: + logger.info( + "proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт " + "%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, " + "иначе проба присвоила бы чужой бан и потом сняла бы его как свой)", + proxy_id, + source, + holder["reason"], + holder["banned_until"], + effective_reason, + ) + return "deferred" - # 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем - # текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет). current = ( db.execute( text( @@ -904,14 +974,15 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = ) if current is None: logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id) - else: - logger.warning( - "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " - "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " - "источнику (голодание хуже, чем работа через забаненный узел).", - proxy_id, - source, - ) + return "missing" + logger.warning( + "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " + "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " + "источнику (голодание хуже, чем работа через забаненный узел).", + proxy_id, + source, + ) + return "protected" def clear_source_bans( @@ -1004,13 +1075,18 @@ def mark_source_probe( Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить - дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. + дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная + половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не + присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь + присвоенная строка уже «своя». Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не пишется (голодание хуже работы через плохой узел). - Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored". + Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned` + ("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном + только реально записанный бан. """ if ok: cleared = clear_source_bans( @@ -1041,8 +1117,7 @@ def mark_source_probe( fail_kind, detail, ) - mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) - return "banned" + 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: diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index bd360aed..478e394a 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -387,6 +387,17 @@ class FakeSession: } self.bans.append(ban) 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 ban["ban_count"] += 1 hours = min(p["base_hours"] * 2 ** (ban["ban_count"] - 1), p["max_hours"]) @@ -419,6 +430,12 @@ class FakeSession: self.bans = [b for b in self.bans if b["banned_until"] >= cutoff] 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 row = self._by_id(p["id"]) if row is None: diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py index 7eca4afd..37fc10fe 100644 --- a/tradein-mvp/backend/tests/test_2800_per_source_probe.py +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -283,11 +283,85 @@ async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] - assert db._ban(1, "avito") is not None, "чужой бан проба снимать не имеет права" + 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. чужие отказы ──────────────────────────────────────────────────────── From 12c189ac27f8e3f0d6898d5560e0545a09065237 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 08:29:26 +0000 Subject: [PATCH 111/130] =?UTF-8?q?fix(tradein/scraper):=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=BA=D0=B0=20=C2=AB=D1=87=D1=82=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B5=D0=B9=D1=87=D0=B0=D1=81=20=D0=BD=D0=B5=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=C2=BB=20=E2=80=94=20=D0=BB?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D0=B8=D1=86=D0=B0=20=D0=B2=D0=B5=D1=85?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B2=D0=B8=D0=B4=D0=B8=D1=82=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=200=20(#2670)=20(#2806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_2670_stale_source_digest.py | 199 ++++++++++++++++++ .../scraper_kit/orchestration/scheduler.py | 150 ++++++++++++- 2 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2670_stale_source_digest.py diff --git a/tradein-mvp/backend/tests/test_2670_stale_source_digest.py b/tradein-mvp/backend/tests/test_2670_stale_source_digest.py new file mode 100644 index 00000000..baf2ec4a --- /dev/null +++ b/tradein-mvp/backend/tests/test_2670_stale_source_digest.py @@ -0,0 +1,199 @@ +"""#2670 (остаток): лестница напоминаний не отвечает на вопрос «что сломано сейчас». + +#2720 вылечил «алерт ровно один раз за серию»: теперь вехи 3, 6, 12, 24, 48… Но лестница +шагает по ПОДРЯД ИДУЩИМ завершённым failed/banned прогонам, а на проде 2026-08-10 три +самых залежавшихся источника из шести просроченных ей недоступны — и лишь один из трёх +из-за редких вех: + + ┌────────────────────────────┬────────┬───────┬──────────────────────────────────────┐ + │ источник │ стрик │ сут. │ когда напомнит лестница │ + ├────────────────────────────┼────────┼───────┼──────────────────────────────────────┤ + │ avito_full_load_exhaustive │ 0 │ 49.5 │ никогда: 5 банов обнулил 'cancelled' │ + │ cian_history_backfill │ 0 │ 42.1 │ никогда: прогонов нет с 30.06 │ + │ avito_full_load │ 31 │ 37.7 │ веха 48 → +17 прогонов × 7 сут = 119 │ + │ avito_detail_backfill │ 5 │ 5.2 │ веха 6 → завтра │ + │ domclick_city_sweep │ 5 │ 5.1 │ веха 6 → завтра │ + │ domclick_detail_backfill │ 4 │ 5.0 │ веха 6 → послезавтра │ + └────────────────────────────┴────────┴───────┴──────────────────────────────────────┘ + +Уплотнение вех (3,4,5,6…) чинит ТОЛЬКО третью строку: у первых двух стрик равен нулю, +уплотнять нечего — «замолчал» там означает «перестал производить прогоны», а не «серия +длиннее последней вехи». Поэтому остаток задачи закрывает сводка, считающая КАЛЕНДАРНЫЙ +возраст последнего успеха, а не длину серии. + +Фальсификация: на коде до этой правки `emit_stale_digest`/`stale_sources` не существует +(ImportError на сборе тестов) — сводки нет ни в каком виде. Тест +`test_ladder_is_silent_for_the_worst_two` — КОНТРОЛЬ: он зелёный и до, и после правки и +показывает ровно то, чего сводка не заменяет, а добавляет: лестница на этих двух молчит. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import runs as kit_runs +from scraper_kit.orchestration import scheduler as sched + +NOW = datetime(2026, 8, 10, 8, 0, tzinfo=UTC) + + +def _row(source: str, interval_days: Any, age_days: float, never_ok: bool = False) -> Any: + """Строка `_STALE_SOURCES_SQL`: last_ok уже схлопнут в `since` через COALESCE.""" + return SimpleNamespace( + source=source, + interval_days=interval_days, + since=NOW - timedelta(days=age_days), + never_ok=never_ok, + ) + + +# Снимок прода 2026-08-10 08:00 UTC: все 52 включённых расписания не влезают, взяты все +# просроченные + четыре контрольных, каждое из которых мимо порога по своей причине. +PROD_ROWS = [ + _row("cian_history_backfill", None, 42.1), # такт по умолчанию (daily) + _row("avito_full_load_exhaustive", 7, 49.5), + _row("avito_full_load", 7, 37.7), + _row("avito_detail_backfill", None, 5.2), + _row("domclick_city_sweep", None, 5.1), + _row("domclick_detail_backfill", None, 5.0), + # ── контроль: НЕ просрочены ── + _row("rosreestr_quarter_poll", 28, 24.0), # 24 сут при такте 28 — норма + _row("sber_index_pull", 7, 4.1), + _row("avito_city_sweep", None, 1.1), + _row("proxy_healthcheck", None, 0.02), +] + +# Порядок — по числу ПРОПУЩЕННЫХ ТАКТОВ (age/interval), а не по календарю: 42 суток +# у суточного backfill'а = 42 пропущенных такта, 49.5 у недельного = 7. +PROD_STALE = [ + "cian_history_backfill", # 42.1 / 1 + "avito_full_load_exhaustive", # 49.5 / 7 = 7.07 + "avito_full_load", # 37.7 / 7 = 5.39 + "avito_detail_backfill", # 5.2 / 1 + "domclick_city_sweep", # 5.1 / 1 + "domclick_detail_backfill", # 5.0 / 1 +] + + +@pytest.fixture(autouse=True) +def _reset_digest_clock() -> Any: + """Выпуск сводки помнится в памяти модуля — сбрасываем между тестами.""" + sched._last_stale_digest_at = None + yield + sched._last_stale_digest_at = None + + +def _db(rows: list[Any]) -> MagicMock: + db = MagicMock() + db.execute.return_value.fetchall.return_value = rows + return db + + +# ── 1. Чистая логика порога ────────────────────────────────────────────────── + + +def test_stale_sources_names_exactly_the_prod_six() -> None: + """Шесть просроченных из десяти, порядок — по числу пропущенных ТАКТОВ, не суток.""" + stale = sched.stale_sources(PROD_ROWS, NOW) + assert [s.source for s in stale] == PROD_STALE + + +def test_quarterly_source_is_not_stale_at_24_days() -> None: + """Порог считается в тактах: 24 сут для 28-суточного poll'а — не просрочка.""" + assert sched.stale_sources([_row("rosreestr_quarter_poll", 28, 24.0)], NOW) == [] + # …а 85 суток (>3×28) — уже просрочка. + assert [s.source for s in sched.stale_sources([_row("q", 28, 85.0)], NOW)] == ["q"] + + +@pytest.mark.parametrize("raw", [None, "null", "", "abc", 0, -5]) +def test_broken_interval_falls_back_to_daily(raw: Any) -> None: + """`interval_days: null` и мусор → такт 1 сут, как у compute_next_run_at.""" + assert sched._schedule_interval_days(raw) == 1 + + +def test_never_successful_source_is_reported_with_a_flag() -> None: + """Расписание без единого 'done' считается от created_at и помечается явно.""" + (only,) = sched.stale_sources([_row("brand_new", 1, 9.0, never_ok=True)], NOW) + assert only.never_ok is True + + +# ── 2. Выпуск сводки ───────────────────────────────────────────────────────── + + +def test_digest_emits_one_event_listing_all_stale_sources() -> None: + sentry = MagicMock() + with patch.object(sched, "sentry_sdk", sentry): + stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW) + assert [s.source for s in stale] == PROD_STALE + sentry.capture_message.assert_called_once() + msg = sentry.capture_message.call_args[0][0] + assert msg.startswith("6 scraper sources are stale") + for name in PROD_STALE: + assert name in msg + assert "rosreestr_quarter_poll" not in msg + + +def test_digest_covers_the_two_sources_the_ladder_cannot_reach() -> None: + """Главное свойство: стрик 0 не мешает сводке — она меряет календарь, а не серию.""" + sentry = MagicMock() + with patch.object(sched, "sentry_sdk", sentry): + stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW) + zero_streak = {"avito_full_load_exhaustive", "cian_history_backfill"} + assert zero_streak <= {s.source for s in stale} + + +def test_digest_is_quiet_when_everything_is_fresh() -> None: + sentry = MagicMock() + fresh = [_row("avito_city_sweep", None, 1.1), _row("sber_index_pull", 7, 4.1)] + with patch.object(sched, "sentry_sdk", sentry): + assert sched.emit_stale_digest(_db(fresh), now=NOW) == [] + sentry.capture_message.assert_not_called() + + +def test_digest_is_daily_not_per_tick() -> None: + """Планировщик тикает раз в минуту; сводка обязана выходить раз в сутки.""" + sentry = MagicMock() + db = _db(PROD_ROWS) + with patch.object(sched, "sentry_sdk", sentry): + sched.emit_stale_digest(db, now=NOW) + sched.emit_stale_digest(db, now=NOW + timedelta(minutes=1)) + sched.emit_stale_digest(db, now=NOW + timedelta(hours=23)) + assert sentry.capture_message.call_count == 1 + sched.emit_stale_digest(db, now=NOW + timedelta(hours=24, minutes=1)) + assert sentry.capture_message.call_count == 2 + + +def test_digest_failure_never_breaks_the_tick() -> None: + """Сводка — best-effort: упавший запрос не имеет права уронить тик планировщика.""" + db = MagicMock() + db.execute.side_effect = RuntimeError("db down") + with patch.object(sched, "sentry_sdk", MagicMock()): + assert sched.emit_stale_digest(db, now=NOW) == [] + + +# ── 3. Контроль: что именно сводка ДОБАВЛЯЕТ к лестнице ────────────────────── + + +@pytest.mark.parametrize( + ("name", "streak"), + [("avito_full_load_exhaustive", 0), ("cian_history_backfill", 0), ("avito_full_load", 31)], +) +def test_ladder_is_silent_for_the_worst_two(name: str, streak: int) -> None: + """КОНТРОЛЬ (зелёный и до правки): у трёх худших источников лестница молчит. + + Стрик 0 — прогонов нет / серию обнулил 'cancelled'; стрик 31 — между вехами 24 и 48. + """ + rows = [SimpleNamespace(status="banned") for _ in range(streak)] + rows += [SimpleNamespace(status="done") for _ in range(3)] + sentry = MagicMock() + with patch.object(kit_runs, "sentry_sdk", sentry): + kit_runs._alert_if_consecutive_failures(_db(rows), name) + sentry.capture_message.assert_not_called() diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 0ddd275e..06d778a8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -17,8 +17,11 @@ Kit-native (sweep-оркестраторы, уже перенесённые в ` осталось в `app` (rosreestr_dkp / sber_index / deactivate_stale / *_backfill / …), инжектируются извне как `Handler` через `build_registry(product_handlers=...)`. -Боевой рантайм (scraper-контейнер) по-прежнему крутит старый `app.services.scheduler` — -это COPY, не MOVE. Переключение — отдельный поздний strangler-шаг. +Боевой рантайм (scraper-контейнер, `python -m app.scheduler_main`) крутит ИМЕННО ЭТОТ +loop: #2397 Part C удалил legacy-ветку `app.services.scheduler.scheduler_loop`, и +`_run_kit_scheduler` остался единственным путём. Строка «по-прежнему крутит старый +app.services.scheduler» жила здесь после того, как перестала быть правдой, и посылала +правку сторожей не в тот файл (#2670). Критичная concurrency-логика (`_claim_run` advisory-lock + double-check, `reap_zombies` порог, heartbeat, SIGTERM-drain) перенесена ДОСЛОВНО — тот же SQL, то же ветвление. @@ -36,6 +39,11 @@ from typing import TYPE_CHECKING, Any from sqlalchemy import text +try: # sentry опционален — kit standalone-импортируем, sentry-sdk не в его зависимостях + import sentry_sdk +except ImportError: # pragma: no cover - в проде backend-env sentry_sdk присутствует + sentry_sdk = None # type: ignore[assignment] + from scraper_kit.orchestration import runs as _kit_runs from scraper_kit.orchestration.pipeline import ( get_city_anchors, @@ -78,6 +86,141 @@ SKIP_CONCURRENT_CLAIM = "concurrent_claim" SKIP_RUNNING_UNDER_LOCK = "running_appeared_under_lock" SKIP_UNKNOWN_SOURCE = "unknown_source" +# ── сводка «что сейчас не собирает» (#2670, второй пункт задачи) ───────────── +# Лестница напоминаний из #2720 считает ПОДРЯД ИДУЩИЕ неудачные ПРОГОНЫ. Прод +# 2026-08-10: шесть источников не имели успешного прогона дольше 3× своего такта, и +# трое худших из них лестнице недоступны ПО ПОСТРОЕНИЮ, а не из-за редких вех: +# +# cian_history_backfill 42.1 сут без успеха, стрик 0 — с 30.06 прогонов нет +# вовсе (сегодняшний единственный — 'skipped', +# cian_cookies_expired), а лестница шагает только по +# завершённым failed/banned; +# avito_full_load_exhaustive 49.5 сут без успеха, стрик 0 — 5 банов подряд обнулил +# один 'cancelled' 09.08 (деплой убил бегущий прогон); +# avito_full_load 37.7 сут без успеха, стрик 31 — веха 48 при такте +# interval_days=7 наступит через 17 прогонов ≈ 119 суток. +# +# Уплотнение вех чинит только третий случай: у первых двух стрик равен нулю, уплотнять +# нечего. Поэтому сводка не «ещё один сторож помельче», а ЕДИНСТВЕННЫЙ ответ на вопрос +# «что сломано сейчас»: она считает КАЛЕНДАРНЫЙ возраст последнего успеха, поэтому +# видит и молчащий источник, и обнулённый стрик, и редкую веху. Лестница остаётся как +# была — она отвечает на другой вопрос («что сломалось только что») и стоит дёшево. +STALE_DIGEST_INTERVAL_FACTOR = 3 +STALE_DIGEST_PERIOD_H = 24 + +# Возраст последнего УСПЕШНОГО ('done') прогона на каждое включённое расписание. +# COALESCE(last_ok, created_at): у расписания без единого успеха отсчёт идёт от его +# создания — иначе «никогда не собирал» выглядело бы как «нет данных, судить нечем». +_STALE_SOURCES_SQL = text(""" + SELECT sch.source, + sch.default_params->>'interval_days' AS interval_days, + COALESCE( + (SELECT max(r.finished_at) FROM scrape_runs r + WHERE r.source = sch.source AND r.status = 'done'), + sch.created_at + ) AS since, + (NOT EXISTS (SELECT 1 FROM scrape_runs r + WHERE r.source = sch.source AND r.status = 'done')) AS never_ok + FROM scrape_schedules sch + WHERE sch.enabled +""") + +# ponytail: последний выпуск сводки помнится В ПАМЯТИ процесса, поэтому рестарт +# scheduler'а (деплой) даёт лишний выпуск. Осознанный размен: альтернатива — таблица +# состояния (миграция) ради анти-спама у механизма, который и заводится ПРОТИВ +# молчания. Понадобится точность — переносить в scrape_runs строкой своего source'а. +_last_stale_digest_at: datetime | None = None + + +@dataclass(frozen=True) +class StaleSource: + """Источник, не собиравший дольше STALE_DIGEST_INTERVAL_FACTOR× своего такта.""" + + source: str + interval_days: int + age_days: float + never_ok: bool + + +def _schedule_interval_days(raw: Any) -> int: + """default_params.interval_days → такт в сутках; всё непонятное → 1 (как у claim'а). + + Тот же дефолт, что у `compute_next_run_at` (interval_days=1 == daily): порог сводки + обязан считаться из ТОГО ЖЕ числа, которым расписание себя двигает, иначе «просрочен» + будет мерить не тот такт. `"interval_days": null` в jsonb приезжает сюда None. + """ + try: + return max(1, int(raw)) + except (TypeError, ValueError): + return 1 + + +def stale_sources(rows: list[Any], now: datetime) -> list[StaleSource]: + """Чистая часть сводки: какие расписания просрочены и на сколько (свежие — внизу). + + Просрочка меряется в ТАКТАХ, а не в сутках: у rosreestr_quarter_poll такт 28 суток, + и 24 суток без сбора для него норма, а для суточного domclick_city_sweep — авария. + Сортировка по числу пропущенных тактов, а не по календарю, по той же причине. + """ + stale: list[StaleSource] = [] + for row in rows: + interval = _schedule_interval_days(row.interval_days) + age_days = (now - row.since).total_seconds() / 86400.0 + if age_days > STALE_DIGEST_INTERVAL_FACTOR * interval: + stale.append( + StaleSource( + source=row.source, + interval_days=interval, + age_days=age_days, + never_ok=bool(row.never_ok), + ) + ) + stale.sort(key=lambda s: s.age_days / s.interval_days, reverse=True) + return stale + + +def emit_stale_digest(db: Session, *, now: datetime | None = None) -> list[StaleSource]: + """Раз в STALE_DIGEST_PERIOD_H часов — одно событие «что сейчас не собирает». + + Возвращает список просроченных источников (пустой — либо всё свежо, либо выпуск ещё + не подошёл по времени). Best-effort, как и оба сторожа в runs.py: сводка не имеет + права уронить тик планировщика. + """ + global _last_stale_digest_at + now = now or datetime.now(UTC) + if _last_stale_digest_at is not None and now - _last_stale_digest_at < timedelta( + hours=STALE_DIGEST_PERIOD_H + ): + return [] + try: + stale = stale_sources(list(db.execute(_STALE_SOURCES_SQL).fetchall()), now) + _last_stale_digest_at = now + if not stale: + logger.info("scheduler: stale-digest — просроченных источников нет (#2670)") + return [] + details = ", ".join( + f"{s.source} {s.age_days:.1f}d/{s.interval_days}d" + + (" (успеха не было ни разу)" if s.never_ok else "") + for s in stale + ) + logger.error( + "scheduler: %d источников не собирают дольше %d× своего такта — %s (#2670)", + len(stale), + STALE_DIGEST_INTERVAL_FACTOR, + details, + ) + if sentry_sdk is not None: + sentry_sdk.capture_message( + f"{len(stale)} scraper sources are stale (no successful run for more than " + f"{STALE_DIGEST_INTERVAL_FACTOR}× their schedule interval): {details}", + level="error", + ) + return stale + except Exception: + logger.exception("scheduler: stale-digest failed") + return [] + + # ── типы job/handler ───────────────────────────────────────────────────────── # Job получает свежую сессию (открыта `_dispatch`), run_id, params и весь контекст # (config/matcher/enrichment/runs) — чтобы иметь доступ к инжектированным зависимостям. @@ -759,6 +902,9 @@ async def scheduler_loop(ctx: SchedulerContext, registry: Mapping[str, Handler]) try: # Reap zombies first reap_zombies(db) + # #2670: раз в сутки — сводка «что сейчас не собирает». Календарная, а + # не по стрику: два самых залежавшихся источника прода имеют стрик 0. + emit_stale_digest(db) # Process due schedules due = get_due_schedules(db) for sch in due: From 84a65d40dd410d2a7172d435041c7c208e80731e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 08:39:59 +0000 Subject: [PATCH 112/130] =?UTF-8?q?fix(site-finder):=20=C2=A74.1=20=C2=AB?= =?UTF-8?q?=D0=9F=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=D1=82=D1=8C=C2=BB=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B2=D0=B5=D1=81=D0=B0=20POI=20(#2790)=20(#281?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/[cad]/AnalysisPageContent.tsx | 51 +++++- .../AnalysisPageContent.weights.test.tsx | 159 ++++++++++++++++++ .../site-finder/WeightProfilePanel.tsx | 15 +- .../Section3SettingsAndCompetitors.tsx | 41 +++-- .../__tests__/useParcelAnalyzeQuery.test.ts | 12 +- frontend/src/lib/api/weightProfiles.ts | 67 +++----- frontend/src/lib/site-finder-api.ts | 42 ++++- 7 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 03be7b0d..41cd3e4d 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast"; import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives"; -import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import { + AnalyzeWeightsContext, + adaptEgrn, + useParcelAnalyzeQuery, +} from "@/lib/site-finder-api"; +import type { PoiCategoryKey } from "@/lib/api/weightProfiles"; import type { ParcelAnalysis, PendingConceptProgram, @@ -29,7 +34,40 @@ interface Props { // ── Page Content (client — needs TanStack Query) ─────────────────────────────── +/** + * Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в + * контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние + * нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы + * контекст «сверху», то есть null, и страница разъехалась бы на два разных + * анализа — свой у шапки, свой у секций. + * + * null = веса не применяли → запрос как раньше, без тела. + */ export function AnalysisPageContent({ cad }: Props) { + const [appliedWeights, setAppliedWeights] = useState | null>(null); + + return ( + + + + ); +} + +function AnalysisPageBody({ + cad, + appliedWeights, + onWeightsApply, +}: Props & { + appliedWeights: Record | null; + onWeightsApply: (weights: Record) => void; +}) { const [horizon, setHorizon] = useState(12); const queryClient = useQueryClient(); @@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) { {/* ── Группа «Стройка и рынок» ──────────────────────────────── */} - {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */} - + {/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1 + поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор + пересчитывается по ползункам во ВСЕХ секциях (#2790). */} + {/* 5. Атмосфера — IMPLEMENTED in A11 */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx new file mode 100644 index 00000000..4cc7e35e --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx @@ -0,0 +1,159 @@ +/** + * #2790 п.1 — «Применить» у весов POI в §4.1 ничего не применяло. + * + * Состояние весов жило в `Section31Settings` и читалось только обратно в ту же + * панель: до `/analyze` оно не доезжало никогда (слова `weights` в + * AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал + * «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам. + * + * Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и + * настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес — карты, + * прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй + * POST /analyze не случается вообще → красный. + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AnalysisPageContent } from "../AnalysisPageContent"; + +// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут +// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) — +// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста. +vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null })); +vi.mock("@/components/site-finder/GateVerdictBanner", () => ({ + GateVerdictBanner: () => null, +})); +vi.mock("@/components/site-finder/HorizonSelector", () => ({ + HorizonSelector: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({ + Section1ParcelInfo: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({ + Section2NetworksUtilities: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({ + Section4Estimate: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({ + Section5Atmosphere: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({ + Section6Forecast: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({ + Section7Concept: () => null, +})); +vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({ + SectionAlternatives: () => null, +})); +vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({ + BestLayoutsBlock: () => null, +})); + +const CAD = "66:41:0702017:131"; + +const ANALYSIS = { + cad_num: CAD, + score: 18.91, + district: { district_name: "Чкаловский" }, + egrn: null, + competitors: [], +}; + +/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */ +const analyzeBodies: Array | undefined> = []; + +const fetchMock = vi.fn(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + analyzeBodies.length = 0; + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input, init) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/analyze")) { + const raw = init?.body; + analyzeBodies.push( + typeof raw === "string" + ? (JSON.parse(raw) as Record) + : undefined, + ); + return jsonResponse(ANALYSIS); + } + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +/** Ползунок конкретной категории по подписи строки в панели весов. */ +function sliderFor(label: string): HTMLInputElement { + const row = screen.getByText(label).closest("div"); + if (!row) throw new Error(`не нашёл строку ползунка «${label}»`); + const input = row.querySelector('input[type="range"]'); + if (!input) throw new Error(`в строке «${label}» нет ползунка`); + return input as HTMLInputElement; +} + +describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => { + it("отправляет ползунки в тело повторного analyze", async () => { + renderPage(); + + // Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе, + // бэкенд считает по системным. Это же и baseline для «стало другим». + await waitFor(() => expect(analyzeBodies.length).toBe(1)); + expect(analyzeBodies[0]).toBeUndefined(); + + fireEvent.click(await screen.findByText("POI Веса")); + fireEvent.change(sliderFor("Парки"), { target: { value: "3" } }); + fireEvent.change(sliderFor("Трамвайные ост. (−)"), { + target: { value: "-2" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Применить" })); + + // Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что + // выставлены ползунками. До фикса второго запроса не было — красный здесь. + await waitFor(() => expect(analyzeBodies.length).toBe(2)); + const applied = analyzeBodies[1]?.weights as Record; + expect(applied.park).toBe(3); + expect(applied.tram_stop).toBe(-2); + // Нетронутые категории уходят как есть — бэкенд мержит поверх системных, + // но панель отправляет полный набор, чтобы ответ совпадал с ползунками. + expect(applied.school).toBe(1.5); + }); +}); diff --git a/frontend/src/components/site-finder/WeightProfilePanel.tsx b/frontend/src/components/site-finder/WeightProfilePanel.tsx index 60fcbe5b..5d5731f5 100644 --- a/frontend/src/components/site-finder/WeightProfilePanel.tsx +++ b/frontend/src/components/site-finder/WeightProfilePanel.tsx @@ -10,6 +10,7 @@ import { POI_LABELS, POI_WEIGHT_MAX, POI_WEIGHT_MIN, + SYSTEM_PROFILE_USER_ID, useCreateProfile, useWeightProfiles, type PoiCategoryKey, @@ -110,7 +111,18 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { } function handleApply() { - onWeightsChange({ ...draft }, selectedProfileId); + // Системный пресет не адресуем через profile_id: resolve_weights() ищет + // профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не + // текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и + // отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для + // пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса, + // а они ровно те, что на ползунках. + const selected = profiles.find((p) => p.id === selectedProfileId) ?? null; + const addressableId = + selected && selected.user_id !== SYSTEM_PROFILE_USER_ID + ? selected.id + : null; + onWeightsChange({ ...draft }, addressableId); } const handleSaveProfile = useCallback(async () => { @@ -258,6 +270,7 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { {profiles.map((p) => ( ))} diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx index 670fb483..6c616440 100644 --- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx +++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx @@ -28,6 +28,10 @@ interface Props { cad: string; /** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */ data: ParcelAnalysis; + /** Уже применённые POI-веса; null = ничего не применяли (системные). */ + weights: Record | null; + /** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */ + onWeightsApply: (weights: Record) => void; } interface FilterState { @@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) { function Section31Settings({ filters, onFiltersChange, + weights, + onWeightsApply, }: { filters: FilterState; onFiltersChange: (f: FilterState) => void; + weights: Record | null; + onWeightsApply: (weights: Record) => void; }) { - const [weights, setWeights] = useState>( - () => ({ ...POI_DEFAULT_WEIGHTS }), - ); - function toggleChip(key: keyof Omit) { onFiltersChange({ ...filters, [key]: !filters[key] }); } - function handleWeightsChange( - newWeights: Record, - _profileId: number | null, - ) { - setWeights(newWeights); - } - const chips: Array<{ key: keyof Omit; label: string; @@ -136,8 +133,8 @@ function Section31Settings({ margin: "4px 0 0", }} > - Фильтры применяются к конкурентам локально — без повторного запроса к - бэкенду + Радиус и фильтры применяются к конкурентам локально. Веса POI — + пересчёт анализа на бэкенде по кнопке «Применить»

@@ -259,8 +256,8 @@ function Section31Settings({ Профиль весов POI @@ -769,7 +766,12 @@ function applyFilters( // ── Section 3 wrapper ───────────────────────────────────────────────────────── -export function Section3SettingsAndCompetitors({ cad, data }: Props) { +export function Section3SettingsAndCompetitors({ + cad, + data, + weights, + onWeightsApply, +}: Props) { const [filters, setFilters] = useState({ radiusKm: 2, onlyUnderConstruction: false, @@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { {/* Sub-sections */}
- + {/* Competitor table — moved before 3.2/3.3 for context */} {filteredCompetitors.length > 0 && ( diff --git a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts index f84f2033..81f0e074 100644 --- a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts +++ b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts @@ -16,6 +16,7 @@ * directly with a real AbortSignal and a per-URL `fetch` stub, under fake * timers, and assert on abort behaviour + the happy path. */ +import { renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── Capture the options passed to useQuery ─────────────────────────────────── @@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42"; * polling queryFn. Reads `captured.options` via a fresh binding so TS control- * flow doesn't pin it (the hook mutates it opaquely through the mock). * - * `useQuery` is fully mocked (it just records its options, no React state), so - * the rules-of-hooks invariant does not apply to this call — disable locally. + * Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые + * веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет + * dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему + * замокан и просто записывает options; провайдера над хуком нет, значит + * контекст = null, то есть ровно тот случай «весов не применяли», который этот + * тест и гоняет. */ function getQueryFn(): CapturedQueryOptions["queryFn"] { - // eslint-disable-next-line react-hooks/rules-of-hooks - useParcelAnalyzeQuery(CAD, 12); + renderHook(() => useParcelAnalyzeQuery(CAD, 12)); const options = captured.options; if (options === null) throw new Error("useQuery options not captured"); return options.queryFn; diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts index 4aa28bd7..1534e8ce 100644 --- a/frontend/src/lib/api/weightProfiles.ts +++ b/frontend/src/lib/api/weightProfiles.ts @@ -27,14 +27,18 @@ export interface WeightProfileCreate { description?: string | null; } -export interface WeightProfileUpdate { - profile_name?: string; - weights?: Record; - is_default?: boolean; - description?: string | null; -} - // ── Constants ───────────────────────────────────────────────────────────────── + +/** + * Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors + * `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py. + * Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`: + * `resolve_weights()` ищет профиль в области владельца, у чужого пользователя + * его не найдёт и молча вернёт системные веса с ответом `source="profile"` + * (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel. + */ +export const SYSTEM_PROFILE_USER_ID = "__system__"; + // ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES. // Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py. @@ -103,13 +107,20 @@ const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles"; // ── Hooks ───────────────────────────────────────────────────────────────────── -/** List all weight profiles for a given user_id. */ +/** + * Профили пользователя + системные пресеты (#2790). + * + * `include_system=true` домешивает в конец списка три общих пресета (Эконом / + * Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`). + * Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в + * проде с 16.05.2026 и не были видны никому. + */ export function useWeightProfiles(userId: string) { return useQuery({ queryKey: ["weight-profiles", userId], queryFn: () => apiFetch( - `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, + `${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`, ), enabled: !!userId, }); @@ -132,35 +143,9 @@ export function useCreateProfile() { }); } -/** Update an existing weight profile by id. */ -export function useUpdateProfile(userId: string, profileId: number) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "PUT", - body: JSON.stringify(payload), - }, - ), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); - }, - }); -} - -/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */ -export function useDeleteProfile(userId: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (profileId) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { method: "DELETE" }, - ), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); - }, - }); -} +// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали +// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет. +// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026, +// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни +// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда; +// понадобится UI — хуки вернутся из истории (мертвее они там не станут). diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index ca64a76d..ebd0542b 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -9,6 +9,7 @@ */ import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { createContext, useContext } from "react"; import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api"; import { abortableSleep } from "@/lib/abortableSleep"; import type { @@ -503,9 +504,36 @@ export interface PoiScoreResponse { const ANALYZE_POLL_INTERVAL_MS = 2000; const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap +/** + * Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли → запрос + * уходит без тела, как и раньше (бэкенд считает по системным весам). + * + * Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)` + * зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) — все они делят один + * ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос. + * Если веса доедут только до части из них, ключи разойдутся: половина страницы + * покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды. + * Контекст держит всех потребителей ключа на одном значении по построению — + * забыть прокинуть проп в новую секцию нельзя. + */ +export const AnalyzeWeightsContext = createContext | null>(null); + export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { + const weights = useContext(AnalyzeWeightsContext); + // Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем. + // null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до + // #2790, кэш не сбрасывается на ровном месте. + const weightsKey = weights + ? JSON.stringify(Object.entries(weights).sort()) + : null; + return useQuery({ - queryKey: ["parcel-analyze", cad, horizon], + // Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI + // мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт. + queryKey: ["parcel-analyze", cad, horizon, weightsKey], // TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts // on unmount and whenever the queryKey changes (смена cad/horizon). Thread // it through the POST/GET fetches and check it before each poll iteration so @@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { cad, )}/analyze?horizon=${horizon}`; + // Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id: + // тело запроса == ползункам панели, и ответ рапортует source="inline" — + // расхождению между показанными весами и посчитанным скором взяться + // неоткуда (в отличие от profile_id, см. #2782). + const analyzeInit: RequestInit = weights + ? { method: "POST", signal, body: JSON.stringify({ weights }) } + : { method: "POST", signal }; + // First request — POST /analyze. apiFetchWithStatus surfaces the 202 // Accepted code instead of treating it as a successful payload. const first = await apiFetchWithStatus< ParcelAnalyzeResponse | AnalyzeAcceptedResponse - >(analyzeUrl, { method: "POST", signal }); + >(analyzeUrl, analyzeInit); // 200 → geometry was cached, full analysis is ready. if (first.status === 200) { @@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { // rather than returning the stub (symmetry with the first request). const second = await apiFetchWithStatus< ParcelAnalyzeResponse | AnalyzeAcceptedResponse - >(analyzeUrl, { method: "POST", signal }); + >(analyzeUrl, analyzeInit); if (second.status === 200) { return second.body as ParcelAnalyzeResponse; } From 72472c2783d868be1b55d21c0be2ec59a7b494af Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 08:50:56 +0000 Subject: [PATCH 113/130] =?UTF-8?q?fix(tradein/newbuilding):=20=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=20=D0=B2=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20(#2807)=20(#2809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 5 +- .../app/tasks/newbuilding_enrich_backfill.py | 63 +++--- .../tasks/test_newbuilding_enrich_backfill.py | 47 +++-- .../tests/test_2767_newbuilding_parse_miss.py | 4 +- .../tests/test_2807_write_counters_honesty.py | 193 ++++++++++++++++++ .../scraper_kit/providers/cian/newbuilding.py | 63 +++++- 6 files changed, 322 insertions(+), 53 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2807_write_counters_honesty.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 89d6ef8d..856a7213 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -1961,8 +1961,9 @@ async def scrape_cian_newbuilding( saved = False if house_id is not None: - # save_newbuilding_enrichment — sync (def, returns None); await на sync-функции - # раньше поднимал TypeError на любом вызове с house_id. + # save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции + # раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт + # записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения. save_newbuilding_enrichment(db, house_id, enrichment) saved = True diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index abd51d13..632ec801 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -109,10 +109,17 @@ class NewbuildingEnrichBackfillResult: failed_fetch: int = 0 # fetch returned None / raised failed_save: int = 0 # save raised after a good fetch - # Row-level deltas (how much actually landed). - price_dynamics_rows: int = 0 - reliability_rows: int = 0 - review_rows: int = 0 + # Сколько РЕАЛЬНО записано, по словам самих писателей (#2807). Раньше здесь стоял + # прирост COUNT(*) по таблице до/после сохранения — то есть «выросла ли таблица», а + # не «сколько записали»: при ON CONFLICT DO UPDATE обновление даёт ноль, а у + # reliability ноль давал ещё и _dedup_reliability, схлопывающий дубль сразу после + # вставки. Ключи переименованы намеренно: у price_dynamics_rows/reliability_rows/ + # review_rows в истории прогонов старый смысл, и молча поменять его под тем же + # именем — ровно тот дефект, ради которого правка и делается. + price_dynamics_inserted: int = 0 # новых точек динамики цен + price_dynamics_updated: int = 0 # существующих точек переписано свежей ценой + reliability_inserted: int = 0 # строк house_reliability_checks вставлено + review_upserted: int = 0 # отзывов записано (вставка+обновление, ключ ext_review_id) duration_sec: float = field(default=0.0) @@ -557,15 +564,15 @@ async def backfill_newbuilding_enrichment( continue # ── Save under a SAVEPOINT so one bad house can't poison the batch ── - # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally, - # so we snapshot the row counts BEFORE and recompute the delta AFTER its commit - # rather than relying on the nested transaction staying open. - pd_before, rc_before, rv_before = _house_enrichment_counts(db, house_id) + # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally. + # COUNT(*) до сохранения нужен ТОЛЬКО для had_reliability (дедуп ниже): сколько + # записано, теперь сообщают сами писатели, а не разница COUNT'ов (#2807). + _, rc_before, _ = _house_enrichment_counts(db, house_id) try: had_reliability = rc_before > 0 # 1) price_dynamics + reliability + houses UPDATE (existing, commits inside). - save_newbuilding_enrichment(db, house_id, enrichment) + saved = save_newbuilding_enrichment(db, house_id, enrichment) # 2) reviews — added here (save_newbuilding_enrichment skips them). # SAVEPOINT around the review write so a malformed review can't lose the @@ -601,16 +608,18 @@ async def backfill_newbuilding_enrichment( sp.rollback() logger.warning("reliability dedup failed house_id=%s: %s", house_id, dexc) - pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id) - result.price_dynamics_rows += max(0, pd_after - pd_before) - result.reliability_rows += max(0, rc_after - rc_before) - result.review_rows += max(0, rv_after - rv_before) + result.price_dynamics_inserted += saved.price_inserted + result.price_dynamics_updated += saved.price_updated + result.reliability_inserted += saved.reliability_inserted + result.review_upserted += review_written result.succeeded += 1 logger.info( - "enriched house_id=%s: +pd=%d +reliability=%d +reviews=%d (parsed reviews=%d)", + "enriched house_id=%s: динамика цен +%d новых / %d обновлено, " + "reliability +%d, отзывов записано %d (распознано %d)", house_id, - max(0, pd_after - pd_before), - max(0, rc_after - rc_before), + saved.price_inserted, + saved.price_updated, + saved.reliability_inserted, review_written, len(enrichment.reviews), ) @@ -629,8 +638,8 @@ async def backfill_newbuilding_enrichment( result.duration_sec = time.time() - t0 logger.info( "newbuilding-enrich backfill done: processed=%d ok=%d skip=%d resolved=%d " - "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " - "| %.1fs", + "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / " + "%d обновлено, reliability +%d, отзывов %d | %.1fs", result.processed, result.succeeded, result.skipped_already_enriched, @@ -638,9 +647,10 @@ async def backfill_newbuilding_enrichment( result.failed_resolve, result.failed_fetch, result.failed_save, - result.price_dynamics_rows, - result.reliability_rows, - result.review_rows, + result.price_dynamics_inserted, + result.price_dynamics_updated, + result.reliability_inserted, + result.review_upserted, result.duration_sec, ) return result @@ -787,8 +797,8 @@ async def run_newbuilding_enrich( ) logger.info( "scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d " - "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " - "| pending=%d %.1fs", + "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / " + "%d обновлено, reliability +%d, отзывов %d | pending=%d %.1fs", run_id, result.processed, result.succeeded, @@ -796,9 +806,10 @@ async def run_newbuilding_enrich( result.failed_resolve, result.failed_fetch, result.failed_save, - result.price_dynamics_rows, - result.reliability_rows, - result.review_rows, + result.price_dynamics_inserted, + result.price_dynamics_updated, + result.reliability_inserted, + result.review_upserted, result.cian_houses_pending, result.duration_sec, ) diff --git a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py index 4df0d729..95ae2c4a 100644 --- a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py @@ -19,7 +19,10 @@ _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 -from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment # noqa: E402 +from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 + NewbuildingEnrichment, + NewbuildingSaveCounts, +) from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402 NewbuildingEnrichBackfillResult, @@ -168,24 +171,37 @@ def _enrichment_with_everything(seed: int = 0) -> NewbuildingEnrichment: def _fake_save_newbuilding_enrichment(db, house_id, enrichment): - """Stand-in for the real saver: lands price_dynamics + reliability into FakeDB.""" + """Stand-in for the real saver: lands price_dynamics + reliability into FakeDB. + + Возвращает NewbuildingSaveCounts, как настоящий (#2807): вставку от обновления + различает сам писатель — снаружи по таблице их не отличить (UPSERT по dim_key). + """ + inserted = updated = 0 for p in enrichment.realty_valuation_chart: if p.get("price_per_sqm") is None: continue - db.price_dynamics.add( - ( - house_id, - p["month_date"], - "cian_realty_valuation", - p.get("room_count", "all"), - p.get("prices_type", "price"), - p.get("period", "halfYear"), - ) + key = ( + house_id, + p["month_date"], + "cian_realty_valuation", + p.get("room_count", "all"), + p.get("prices_type", "price"), + p.get("period", "halfYear"), ) + if key in db.price_dynamics: + updated += 1 + else: + inserted += 1 + db.price_dynamics.add(key) + reliability = 0 for c in enrichment.reliability_checks: if c.get("check_name") or c.get("check_status"): db.reliability.append((house_id, "cian_nashdom")) + reliability += 1 db.commit() + return NewbuildingSaveCounts( + price_inserted=inserted, price_updated=updated, reliability_inserted=reliability + ) # --------------------------------------------------------------------------- @@ -279,9 +295,10 @@ async def test_backfill_populates_all_three_tables() -> None: assert len(db.price_dynamics) == 2 # 1 chart point × 2 houses assert len(db.reliability) == 2 assert len(db.reviews) == 4 # 2 reviews × 2 houses - assert result.price_dynamics_rows == 2 - assert result.reliability_rows == 2 - assert result.review_rows == 4 + assert result.price_dynamics_inserted == 2 + assert result.price_dynamics_updated == 0 + assert result.reliability_inserted == 2 + assert result.review_upserted == 4 @pytest.mark.asyncio @@ -544,7 +561,7 @@ async def test_run_wrapper_marks_done_and_passes_params(monkeypatch: pytest.Monk async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None): # on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем. seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec) - return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_rows=2) + return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_inserted=2) monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake_backfill) monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None) diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py index 81056835..fea61b82 100644 --- a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -185,7 +185,7 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None: _stub_backfill( monkeypatch, NewbuildingEnrichBackfillResult( - processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_rows=7 + processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7 ), ) calls = _stub_finalisers(monkeypatch) @@ -196,5 +196,5 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None: assert counters["attempted"] == 10 assert counters["enriched"] == 3 assert counters["failed"] == 7 - assert counters["price_dynamics_rows"] == 7 # исходные счётчики на месте + assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте assert counters["succeeded"] == 3 diff --git a/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py new file mode 100644 index 00000000..69b93713 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py @@ -0,0 +1,193 @@ +"""#2807: счётчик мерил прирост таблицы, а читался как «сколько записали». + +`newbuilding_enrich_backfill` считал свою работу разницей `COUNT(*)` до и после +сохранения. Вставка в houses_price_dynamics идёт `ON CONFLICT … DO UPDATE`, поэтому +обновление существующей точки давало ноль. Прод 10.08: прогон 3578 отчитался +`price_dynamics_rows: 0`, обновив за своё окно **64 строки по 10 домам** — те самые, +что вставил прогон 3563 накануне (у него в тех же counters стояло 64). Ноль читался +как «динамика цен снова не пишется». + +Соседние счётчики врали в том же месте по своим причинам: `reliability_rows` обнулял +`_dedup_reliability`, схлопывающий строку сразу после вставки, а `review_rows` +игнорировал число, которое `_save_cian_reviews` УЖЕ возвращал, в пользу разницы COUNT'ов. + +Фальсификация (см. прогон в PR): на коде до правки `test_second_pass_reports_updates` +даёт `price_dynamics_rows == 0` при 64 переписанных точках — ровно прод-симптом. + +Отдельно проверяется, что правка НЕ ослабила сторожа нулевого результата: он смотрит на +`attempted`/`enriched`/`gone`/`blocked` (#2695), а не на счётчики записи, и прогон, +который ничего не обогатил, обязан остаться 'failed' при любых числах в `*_written`. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +sys.modules.setdefault("weasyprint", MagicMock()) + +from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 + NewbuildingSaveCounts, + save_newbuilding_enrichment, +) + +from app.services import scrape_runs as runs_mod # noqa: E402 +from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402 + NewbuildingEnrichBackfillResult, +) + +# Прод-масштаб прогона 3578: 10 домов × 64/10 точек. Держим ровно 64, чтобы число в +# тесте совпадало с числом в задаче. +PROD_POINTS = 64 + + +class _UpsertDB: + """Сессия, у которой houses_price_dynamics уже населена (второй проход). + + `RETURNING (xmax = 0)` возвращает False на конфликте — это и есть «обновили». + """ + + def __init__(self, *, already_present: bool) -> None: + self.already_present = already_present + self.price_writes = 0 + self.reliability_writes = 0 + self.committed = False + + def execute(self, statement, params=None): + sql = str(statement) + res = MagicMock() + if "INSERT INTO houses_price_dynamics" in sql: + self.price_writes += 1 + assert "RETURNING (xmax = 0)" in sql, "писатель обязан различать вставку и update" + res.fetchone.return_value = (not self.already_present,) + return res + if "INSERT INTO house_reliability_checks" in sql: + self.reliability_writes += 1 + res.fetchone.return_value = None + return res + + def commit(self) -> None: + self.committed = True + + +def _enrichment(points: int): + from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment + + return NewbuildingEnrichment( + cian_internal_house_id=1, + cian_zhk_url="https://zhk-x.cian.ru/", + name="ЖК Тест", + realty_valuation_chart=[ + { + "month_date": f"2026-{(i % 12) + 1:02d}-01", + "room_count": "all", + "prices_type": "price", + "period": "halfYear", + "price_per_sqm": 150000.0 + i, + } + for i in range(points) + ], + reliability_checks=[{"check_name": "Надёжный застройщик", "check_status": "reliable"}], + reviews=[], + ) + + +# ── 1. Писатель различает вставку и обновление ─────────────────────────────── + + +def test_first_pass_reports_inserts() -> None: + db = _UpsertDB(already_present=False) + counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS)) + assert counts.price_inserted == PROD_POINTS + assert counts.price_updated == 0 + assert counts.reliability_inserted == 1 + assert db.price_writes == PROD_POINTS + + +def test_second_pass_reports_updates() -> None: + """Прод-симптом: те же 64 точки, ничего нового — но записаны все 64. + + До правки этот прогон отчитывался нулём по всем трём счётчикам. + """ + db = _UpsertDB(already_present=True) + counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS)) + assert counts.price_inserted == 0 + assert counts.price_updated == PROD_POINTS + assert counts.price_written == PROD_POINTS + assert db.price_writes == PROD_POINTS + + +def test_nothing_to_write_stays_zero() -> None: + """Встречная проверка: пустой график — ноль и во «вставлено», и в «обновлено».""" + db = _UpsertDB(already_present=True) + counts = save_newbuilding_enrichment(db, 42, _enrichment(0)) + assert (counts.price_inserted, counts.price_updated, counts.price_written) == (0, 0, 0) + + +def test_points_without_price_are_not_counted_as_written() -> None: + """Точка без price_per_sqm пропускается писателем — и не попадает в счёт.""" + enrichment = _enrichment(2) + enrichment.realty_valuation_chart[0]["price_per_sqm"] = None + db = _UpsertDB(already_present=False) + counts = save_newbuilding_enrichment(db, 42, enrichment) + assert counts.price_written == 1 + assert db.price_writes == 1 + + +# ── 2. Сторож нулевого результата не ослаблен ──────────────────────────────── + + +def _finalize(counters: dict[str, int]) -> str: + """Прогнать counters через боевой финализатор и вернуть выбранный статус.""" + chosen: dict[str, str] = {} + with ( + patch.object(runs_mod, "mark_done", lambda *a, **k: chosen.setdefault("s", "done")), + patch.object(runs_mod, "mark_failed", lambda *a, **k: chosen.setdefault("s", "failed")), + patch.object(runs_mod, "mark_banned", lambda *a, **k: chosen.setdefault("s", "banned")), + ): + runs_mod.mark_backfill_finished(MagicMock(), 1, counters, source="newbuilding_enrich") + return chosen["s"] + + +def test_watchdog_still_fails_a_run_that_enriched_nothing() -> None: + """Прогон без обогащений остаётся 'failed', сколько бы записей ни насчитали. + + Числа записи в решение сторожа не входят вовсе — он судит по attempted/enriched. + Если бы входили, честный счётчик «обновлено» превратил бы холостой прогон в успех. + """ + result = NewbuildingEnrichBackfillResult( + processed=25, + succeeded=0, + failed_fetch=25, + price_dynamics_updated=PROD_POINTS, # «что-то писали» — но никого не обогатили + ) + assert _finalize(result.to_backfill_counters()) == "failed" + + +def test_watchdog_verdict_ignores_the_new_keys() -> None: + """Явно: добавление/убирание новых ключей не двигает вердикт ни в одну сторону.""" + base = {"attempted": 25, "enriched": 3, "failed": 22} + assert _finalize(dict(base)) == "done" + assert _finalize({**base, "price_dynamics_inserted": 0, "price_dynamics_updated": 0}) == "done" + zero = {"attempted": 25, "enriched": 0, "failed": 25} + assert _finalize(dict(zero)) == "failed" + assert _finalize({**zero, "price_dynamics_updated": 999}) == "failed" + + +def test_counters_carry_both_numbers_into_the_run() -> None: + """В scrape_runs.counters уезжают ОБА числа — ноль одного больше не читается как ноль.""" + counters = NewbuildingEnrichBackfillResult( + processed=25, succeeded=25, price_dynamics_updated=PROD_POINTS + ).to_backfill_counters() + assert counters["price_dynamics_inserted"] == 0 + assert counters["price_dynamics_updated"] == PROD_POINTS + # Старые имена не должны остаться: у них в истории прогонов другой смысл. + assert "price_dynamics_rows" not in counters + assert "reliability_rows" not in counters + assert "review_rows" not in counters + + +def test_save_counts_written_is_the_sum() -> None: + assert NewbuildingSaveCounts(price_inserted=3, price_updated=4).price_written == 7 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index 4211f73f..58bede93 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -594,11 +594,38 @@ def _extract_nested_offers(offers_state: dict[str, Any]) -> list[dict[str, Any]] # ---- save helpers ---- +@dataclass(frozen=True) +class NewbuildingSaveCounts: + """Что прогон РЕАЛЬНО записал — вставил и обновил отдельно (#2807). + + Заводится потому, что вызывающий мерил свою работу разницей ``COUNT(*)`` по таблице + до и после сохранения. Это прирост ЧИСЛА СТРОК, а не число записанных точек: у + houses_price_dynamics вставка идёт ``ON CONFLICT … DO UPDATE``, поэтому обновление + уже существующей точки даёт ноль. Прод 10.08: прогон 3578 отчитался + ``price_dynamics_rows: 0``, обновив за своё окно 64 строки по 10 домам (их вставил + прогон 3563 накануне) — ноль читался как «динамика цен не пишется». + + Единственный, кто знает разницу, — сам писатель: ``RETURNING (xmax = 0)`` отличает + вставку от обновления (та же идиома, что в + ``backend/app/services/scrapers/gisogd66.py``). Поэтому число возвращается отсюда, а + не восстанавливается снаружи по таблице. + """ + + price_inserted: int = 0 + price_updated: int = 0 + reliability_inserted: int = 0 + + @property + def price_written(self) -> int: + """Сколько точек динамики прошло через запись (вставка + обновление).""" + return self.price_inserted + self.price_updated + + def save_newbuilding_enrichment( db: Any, house_id: int, enrichment: NewbuildingEnrichment, -) -> None: +) -> NewbuildingSaveCounts: """Persist NewbuildingEnrichment to DB. Steps: @@ -606,6 +633,9 @@ def save_newbuilding_enrichment( 2. UPDATE houses with Cian metadata (incl. cian_zhk_url if present) 3. INSERT INTO houses_price_dynamics (chart points, ON CONFLICT DO UPDATE) 4. INSERT INTO house_reliability_checks (overall + details) + + Returns NewbuildingSaveCounts — вставлено/обновлено раздельно (#2807). Вызывающие, + которым счёт не нужен (SERP-sweep, admin re-enrich), просто игнорируют результат. """ from sqlalchemy import text @@ -681,14 +711,18 @@ def save_newbuilding_enrichment( # 3. INSERT houses_price_dynamics # UNIQUE constraint: houses_price_dynamics_dim_key # (house_id, source, room_count, prices_type, period, month_date) — per migration 029 - chart_saved = 0 + price_inserted = 0 + price_updated = 0 for point in enrichment.realty_valuation_chart: if point.get("price_per_sqm") is None: continue room_count = point.get("room_count") or "all" prices_type = point.get("prices_type") or "price" period = point.get("period") or "halfYear" - db.execute( + # RETURNING (xmax = 0): у только что вставленной строки xmax равен нулю, у + # обновлённой конфликтом — id транзакции. Без этого «вставили» и «обновили» + # снаружи неразличимы, и обновление читается как «ничего не записали» (#2807). + written = db.execute( text(""" INSERT INTO houses_price_dynamics ( house_id, month_date, source, @@ -705,6 +739,7 @@ def save_newbuilding_enrichment( ON CONFLICT ON CONSTRAINT houses_price_dynamics_dim_key DO UPDATE SET price_per_sqm = EXCLUDED.price_per_sqm, recorded_at = NOW() + RETURNING (xmax = 0) AS is_insert """), { "hid": house_id, @@ -714,12 +749,16 @@ def save_newbuilding_enrichment( "pd": period, "pps": point["price_per_sqm"], }, - ) - chart_saved += 1 + ).fetchone() + if written is not None and written[0]: + price_inserted += 1 + else: + price_updated += 1 # 4. INSERT house_reliability_checks (stores overall check + details array) # Schema (025): (house_id, check_status, check_name, details jsonb, source, recorded_at) # No UNIQUE constraint — caller should manage duplicates if needed + reliability_inserted = 0 for check in enrichment.reliability_checks: if not check.get("check_name") and not check.get("check_status"): continue @@ -743,15 +782,23 @@ def save_newbuilding_enrichment( "det": json.dumps(check.get("details") or [], ensure_ascii=False), }, ) + reliability_inserted += 1 db.commit() logger.info( - "Cian newbuilding saved house_id=%s (chart=%d points, reliability=%d checks, mc_id=%s)", + "Cian newbuilding saved house_id=%s (chart: +%d new / %d updated, " + "reliability=%d checks, mc_id=%s)", house_id, - chart_saved, - len(enrichment.reliability_checks), + price_inserted, + price_updated, + reliability_inserted, mc_id, ) + return NewbuildingSaveCounts( + price_inserted=price_inserted, + price_updated=price_updated, + reliability_inserted=reliability_inserted, + ) async def resolve_cian_zhk_url( From 272abac4d2895c814cec55ebdd01edc19c1fc46e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 08:51:14 +0000 Subject: [PATCH 114/130] =?UTF-8?q?fix(tradein/matching):=20=D0=B3=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=20=D1=80=D0=B0=D0=B7=D0=B2=D1=91=D1=80=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B4=D0=BE=D0=B5=D0=B7=D0=B6=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D1=81=D1=82=D1=80=D0=B0=D0=B6=D0=B0=20Tier-2?= =?UTF-8?q?a=20=E2=80=94=20=D0=BC=D0=B5=D0=B6=D0=B3=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=B8=D0=B5=20=D1=81=D0=BA=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2=20(#2777)=20(#280?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/matching/houses.py | 51 +++++++-- .../backend/app/services/scraper_adapters.py | 2 + .../tests/test_cian_bti_house_persist.py | 9 +- tradein-mvp/backend/tests/test_matching.py | 105 ++++++++++++++++++ .../test_matching_tier_reachability_2674.py | 24 ++++ .../scraper-kit/src/scraper_kit/base.py | 25 ++++- .../scraper-kit/src/scraper_kit/contracts.py | 6 + .../src/scraper_kit/providers/cian/detail.py | 6 +- 8 files changed, 215 insertions(+), 13 deletions(-) diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 413b82ca..1effa723 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -76,6 +76,7 @@ def match_or_create_house( year_built: int | None = None, building_cadastral_number: str | None = None, source_url: str | None = None, + city: str | None = None, ) -> tuple[int | None, float, str]: """Match existing house or create new canonical record. @@ -88,6 +89,18 @@ def match_or_create_house( NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля. ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС. + Args: + city: город-цель развёртки, собравшей эту карточку (`save_listings(city=…)`, + он же `listings.city`) — НЕЗАВИСИМОЕ от строки адреса наблюдение города + (#2777). Нужен ровно там, где адресный токен города бессилен: областной + формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный + ключ Tier-2a вырождается в один нормализованный адрес и становится + глобально уникальным. Опционален: вызывающие без sweep-контекста + (estimate-путь, ad-hoc скрипты) передают None → поведение прежнее. + Про независимость: в #2690 доказано, что усиление ключа полем, выведенным + из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает — + здесь признак приходит другим каналом (какой город запрашивала развёртка). + Returns: (house_id, confidence ∈ [0.0, 1.0], method ∈ { 'cadastr_exact', 'source_exact', 'fingerprint', @@ -212,18 +225,40 @@ def match_or_create_house( # SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of # scope, only relevant once the oblast sweep is enabled. # - # EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ - # city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the - # dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b - # exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito - # SERP format) carries no signal here and is deliberately left on the unchanged path; that - # residual needs sweep-context and is out of this fix's scope. - _resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None + # EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to + # be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all + # (resolved city None) run Tier-2a/2b exactly as before. + # + # #2777: the residual the comment above used to describe as out of scope — a BARE oblast + # card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg. + # The sweep already knows which city it was crawling and stamps it on the listing row + # (save_listings → listings.city); that observation just never reached this guard, so + # 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod + # 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token + # at all — i.e. a globally unique 'street + number' that ANY city's card can hit. + # The address token still wins when present (it describes THIS card; the sweep city + # describes the batch). + _resolved_city = None + if lat is None and lon is None: + _resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None) _skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN + # Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со + # «сшитыми» городами по метке listings.city): + # • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого + # города → страж срабатывает; + # • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж + # молчит: города владельца алиаса мы не знаем (в house_address_aliases его + # нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303 + # бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1). + # • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км + # разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель + # «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый + # пункт; это ограничение данных, а не недоделка стража. if _skip_oblast_alias: logger.info( - "house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s", + "house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s", _resolved_city, + city, norm_addr, ext_source, ) diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 2192cd8c..01bf055a 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -67,6 +67,7 @@ class RealMatcherAdapter: year_built: int | None = None, building_cadastral_number: str | None = None, source_url: str | None = None, + city: str | None = None, ) -> tuple[int | None, float, str]: # house_id is None when the matcher refuses a numberless address without a # cadastral number (method 'no_house_number', P1). Callers must tolerate None. @@ -80,6 +81,7 @@ class RealMatcherAdapter: year_built=year_built, building_cadastral_number=building_cadastral_number, source_url=source_url, + city=city, ) def upsert_listing_source( diff --git a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py index 1c44a0e6..1210e5ec 100644 --- a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py +++ b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py @@ -57,8 +57,9 @@ def _mock_db_bti( address: str | None = "Екатеринбург, улица Малышева, 51", lat: float = 56.83, lon: float = 60.6, + city: str | None = "Екатеринбург", ) -> MagicMock: - """Mock db: SELECT address/lat/lon FROM listings → mappings().first() dict.""" + """Mock db: SELECT address/city/lat/lon FROM listings → mappings().first() dict.""" db = MagicMock() @contextmanager @@ -72,9 +73,10 @@ def _mock_db_bti( mock_result = MagicMock() mock_result.fetchone.return_value = None mock_result.scalar_one_or_none.return_value = None - if "SELECT address, lat, lon FROM listings" in sql_str: + if "SELECT address, city, lat, lon FROM listings" in sql_str: mock_result.mappings.return_value.first.return_value = { "address": address, + "city": city, "lat": lat, "lon": lon, } @@ -110,6 +112,9 @@ def test_bti_data_present_updates_house_columns_via_coalesce(): assert kwargs["ext_source"] == "cian_bti" assert kwargs["ext_id"] == "123" assert kwargs["address"] == "Екатеринбург, улица Малышева, 51" + # #2777: город-цель развёртки (listings.city) — независимое от адреса наблюдение; + # без него бескоординатная BTI-карточка матчится в одноимённый дом другого города. + assert kwargs["city"] == "Екатеринбург" call = _bti_update_call(db) assert call is not None, "UPDATE houses с BTI-полями не найден" diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index f0a0c371..e0066101 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -844,6 +844,111 @@ def test_tier2a_coord_less_bare_street_still_runs_tier2a(): assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) +# --------------------------------------------------------------------------- +# match_or_create_house — sweep-city guard for BARE oblast cards (#2777) +# --------------------------------------------------------------------------- + + +def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups(): + """RED до #2777. Бескоординатная карточка областного формата («ул. Кирова,4» — города + в адресе НЕТ) собрана развёрткой Серова. Ключ Tier-2a без координат вырождается в один + нормализованный адрес, глобально уникальный в house_address_aliases, поэтому карточка + села бы в одноимённый дом другого города (прод: 362.8 км, дом 380628). Признак города у + развёртки ЕСТЬ — он же пишется в listings.city — и теперь доезжает до стража.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss (Tier 1) + # Tier 2a/2b SKIPPED стражем, Tier 3 SKIPPED (нет координат) + {"id": 2777}, # INSERT RETURNING id (New house) + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, "avito", "ext-2777-serov", address="ул. Кирова,4", city="Серов" + ) + assert (house_id, conf, method) == (2777, 1.0, "new") + sqls = _executed_sqls(db) + assert not any( + "fingerprint = :fp" in s for s in sqls + ), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»" + assert not any("normalized_address = :na" in s for s in sqls) + + +def test_sweep_city_ekb_keeps_tier2a_dedup(): + """Контроль к предыдущему: тот же бескоординатный ключ, но развёртка ЕКБ — страж молчит, + Tier-2a дедуп работает как раньше. Иначе правка ломала бы 31 663 ЕКБ-карточки ради 35.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"house_id": 55}, # Tier 2a fingerprint HIT + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, "avito", "ext-2777-ekb", address="ул. Кирова,4", city="Екатеринбург" + ) + assert (house_id, conf, method) == (55, 0.9, "fingerprint") + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + +def test_sweep_city_ignored_when_card_has_coords(): + """Карточка С координатами стражем города не трогается: у Tier-2a координаты уже в ключе, + у Tier-2b свой гео-страж 3 км. Расширять на неё городской признак — значит ломать + смежные ЕКБ/В.Пышма пары, которые в проде расходятся на 2-8 м (то же здание).""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"house_id": 66}, # Tier 2a fingerprint HIT (координаты в ключе) + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, + "avito", + "ext-2777-coords", + address="ул. Кирова,4", + lat=59.60, + lon=60.58, + city="Верхняя Пышма", + ) + assert (house_id, conf, method) == (66, 0.9, "fingerprint") + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + +def test_sweep_city_guard_covers_cities_outside_the_token_list(): + """Страж не должен зависеть от списка _CITY_TOKENS: развёртка Ревды (её в списке нет) + всё равно не имеет права садиться на чужой алиас. Признак — имя города от развёртки, + а не токен, вычитанный из адреса.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"id": 2778}, # INSERT RETURNING id (New house) — алиасы пропущены + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, _conf, method = match_or_create_house( + db, "avito", "ext-2777-revda", address="ул. Кирова,4", city="Ревда" + ) + assert (house_id, method) == (2778, "new") + assert not any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + # --------------------------------------------------------------------------- # match_or_create_listing — mock DB tier routing # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py index 80bca572..b16be85b 100644 --- a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -107,3 +107,27 @@ def test_house_key_never_accepts_flat_cadastre() -> None: assert ( "cad = building_cadastral_number\n" in src ), "в ключ дома вернулся фолбэк на кадастр квартиры" + + +def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None: + """Страж города бесполезен, пока признак не доезжает до него с настоящего вызова (#2777). + + Тот же класс ошибки, что у `house_fias_id`: параметр в сигнатуре есть, передать его + некому. Здесь граница — `scraper_kit.base._link_listing_to_house`, единственный боевой + вызывающий пути создания домов; город он получает из `save_listings`, который его же + пишет в `listings.city`. + """ + from scraper_kit.base import _link_listing_to_house, save_listings + + assert "city" in _params(match_or_create_house) + + hook_src = inspect.getsource(_link_listing_to_house) + assert "city=city" in hook_src, ( + "_link_listing_to_house перестал передавать город в матчер — страж #2777 снова " + "недостижим, а межгородская склейка молча вернётся" + ) + # save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать + # именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен. + assert "city=lot_city" in inspect.getsource( + save_listings + ), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index e35505e0..36f8e6e3 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -601,6 +601,17 @@ def save_listings( metro_stations = EXCLUDED.metro_stations, listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date), area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2), + -- #2777: ДОзаполнение адреса — порядок аргументов обратный остальным, + -- существующее значение выигрывает. Адрес не обновлялся при конфликте + -- вообще: строка, вставленная без адреса (SERP-вариант его не дал), + -- оставалась безадресной НАВСЕГДА, даже когда следующий скрейп адрес + -- приносил. Прод 2026-08-10: 862 строки с address IS NULL, у 95 из них + -- при этом ЕСТЬ house_id_fk — матчинг в тот раз получил адрес и сматчил + -- корректно (fingerprint/new без адреса невозможны), в колонке же + -- остался NULL, и он же кормит геокодер мусором. Перезаписывать НЕЛЬЗЯ: + -- миграции 062/108/124 чистят listings.address, свежий сырой адрес от + -- площадки молча откатил бы эту чистку. + address = COALESCE(listings.address, EXCLUDED.address), -- #2594: город развёртки — COALESCE, чтобы caller без city (ad-hoc -- admin/manual пути, city=None) не затирал уже известный город. city = COALESCE(EXCLUDED.city, listings.city), @@ -714,6 +725,9 @@ def save_listings( metro_stations = CAST(:metro_stations AS jsonb), listing_date = COALESCE(:listing_date, listing_date), area_m2 = COALESCE(:area_m2, area_m2), + -- #2777: см. ON CONFLICT выше — дозаполняем адрес, + -- существующее значение выигрывает. + address = COALESCE(address, :address), city = COALESCE(:city, city), kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2), ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m), @@ -834,7 +848,7 @@ def save_listings( if listing_id is not None: try: with db.begin_nested(): - _link_listing_to_house(db, listing_id, lot, matcher) + _link_listing_to_house(db, listing_id, lot, matcher, city=lot_city) matched += 1 except Exception as e: # Best-effort hook: log and continue so the listings batch isn't aborted. @@ -872,7 +886,7 @@ def _to_json(value: Any) -> str: def _link_listing_to_house( - db: Session, listing_id: int, lot: ScrapedLot, matcher: HouseMatcher + db: Session, listing_id: int, lot: ScrapedLot, matcher: HouseMatcher, *, city: str | None = None ) -> None: """Hook scraped listing into matching service: resolve house, upsert listing_sources. @@ -890,6 +904,12 @@ def _link_listing_to_house( ext_id source: lot.source_id if present, else dedup_hash (Yandex without stable source_id falls back to URL-based dedup_hash — same hash on re-scrape). + `city` — город-цель ЭТОГО batch'а после гео-guard'а (`lot_city` в save_listings, + он же попадает в `listings.city`). Отдаётся матчеру как независимое от строки адреса + наблюдение города (#2777): бескоординатный ключ Tier-2a вырождается в один + нормализованный адрес, а областной формат Avito SERP («ул. Кирова,4») города не + называет — без этого признака карточка из Серова матчится в дом Каменска-Уральского. + Skips silently if: - lot has no source_id AND no address/lat/lon (cannot match house anyway) @@ -925,6 +945,7 @@ def _link_listing_to_house( # по-прежнему пишутся save_listings'ом, теряется только ложная идентичность. building_cadastral_number=lot.building_cadastral_number, source_url=lot.house_url or lot.source_url, + city=city, ) # Mirror the resolved house into listings.house_id_fk so direct diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py index 9a9c822b..931648dc 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py @@ -66,9 +66,15 @@ class HouseMatcher(Protocol): year_built: int | None = ..., building_cadastral_number: str | None = ..., source_url: str | None = ..., + city: str | None = ..., ) -> tuple[int | None, float, str]: """Найти или создать канонический дом. + NB (#2777): `city` — город-цель развёртки этой карточки (тот же, что уходит в + `listings.city`). Единственное наблюдение города, НЕ выведенное из строки адреса; + без него бескоординатная карточка областного формата («ул. Кирова,4») матчится в + одноимённый дом другого города. + NB (#2674): `cadastral_number` (кадастр КВАРТИРЫ) из контракта УБРАН. Ключ дома — только `building_cadastral_number`; квартирный номер в нём породил бы по дому на квартиру, см. комментарий у `cad` в matching/houses.py. diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py index fa1e0eea..85dfbd4f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py @@ -575,7 +575,10 @@ def _persist_cian_bti_house( """ row = ( db.execute( - text("SELECT address, lat, lon FROM listings WHERE id = CAST(:lid AS bigint)"), + # city (#2777): развёртка уже пометила строку городом-целью — единственное + # наблюдение города, не выведенное из строки адреса. Без него бескоординатная + # карточка садится в одноимённый дом другого города (Tier-2a). + text("SELECT address, city, lat, lon FROM listings WHERE id = CAST(:lid AS bigint)"), {"lid": listing_id}, ) .mappings() @@ -595,6 +598,7 @@ def _persist_cian_bti_house( address=row["address"], lat=row["lat"], lon=row["lon"], + city=row["city"], ) if house_id is None: logger.info( From ab5c4b86cdcb0f5a8fd659359f4612e241bcecc0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 14:32:25 +0500 Subject: [PATCH 115/130] =?UTF-8?q?fix(tradein/avito):=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=BE=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D1=83?= =?UTF-8?q?=D0=B5=D0=B7=D0=B6=D0=B0=D1=82=D1=8C=20=D0=B2=20=D0=B0=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D1=81=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20(#2814)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Авито с 27.07 рендерит рейтинг и число отзывов внутри того же

в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4 отзыва». Прод 2026-08-10: 1 123 активных объявления с таким адресом, и у 1 123 из 1 123 нет координат — доля 100%, 711 из них геокодер уже пробовал. Контроль в тех же данных: у чистых адресов координаты есть у 4 766 из 5 715 (83%). Строка без geom молча выпадает из comp-пула: Tier W отбирает через ST_DWithin, а NULL не проходит предикат и нигде не считается. 2 446 из 3 270 безкоординатных попадают в свежий пул аналогов — 12.7% аналогов невидимы радиусному поиску. Режем по «·», за которой идёт ЦИФРА (рейтинг «·4,9», счётчик «·2 отзыва»). По любой «·» нельзя: разделитель района пишется «, 59 · р-н Академический» — за точкой буква, и этот хвост _deglue_house_marker намеренно сохраняет (#1773). Тест проверяет обе стороны плюс три прежних хвоста (CSS, метро, «от N мин.»). Чинит только новые вставки: base.py пишет address = COALESCE(listings.address, EXCLUDED.address), адрес при конфликте не перезаписывается осознанно (#2777). Бэкфилл 1 123 существующих строк вынесен в #2814 для database-expert. --- .../test_avito_clean_address_rating_tail.py | 59 +++++++++++++++++++ .../src/scraper_kit/providers/avito/serp.py | 10 +++- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py new file mode 100644 index 00000000..2e9da4bf --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py @@ -0,0 +1,59 @@ +"""Адрес Авито не должен утаскивать за собой рейтинг дома и число отзывов. + +Прод 2026-08-10: 1 123 активных объявления Авито с адресом вида +«ул. Ткачей,17·5,0 · 4 отзыва», и у 1 123 из 1 123 нет координат — доля 100%. +У объявлений с чистым адресом координаты есть у 4 766. Хвост появился 27.07.2026, +когда Авито начал рендерить рейтинг внутри того же

в item-location. +""" + +from __future__ import annotations + +import pytest +from scraper_kit.providers.avito.serp import _clean_address + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + # Живые строки с прода — рейтинг + счётчик отзывов. + ("Авиационная ул.,10·4,9 · 11 отзывов", "Авиационная ул.,10"), + ("ул. 8 Марта,204Г/2·4,3 · 3 отзыва", "ул. 8 Марта,204Г/2"), + ("ул. Академика Шварца,18к2·1 отзыв", "ул. Академика Шварца,18к2"), + # Только счётчик, без рейтинга. + ("ул. Фурманова,59·2 отзыва", "ул. Фурманова,59"), + # Пробелы вокруг разделителя. + ("ул. Ткачей, 17 · 5,0 · 4 отзыва", "ул. Ткачей, 17"), + ], +) +def test_rating_tail_stripped(raw: str, expected: str) -> None: + assert _clean_address(raw) == expected + + +def test_district_marker_after_dot_survives() -> None: + """Режем «·» только перед цифрой — район за точкой остаётся на месте. + + Иначе фикс рейтинга съел бы разделитель района, который _deglue_house_marker + намеренно восстанавливает (#1773). + """ + assert ( + _clean_address("улица Вильгельма де Геннина, 59 · р-н Академический") + == "улица Вильгельма де Геннина, 59 · р-н Академический" + ) + + +def test_clean_address_unchanged() -> None: + assert _clean_address("с. Новоалексеевское, ул. 8 Марта,35") == ( + "с. Новоалексеевское, ул. 8 Марта,35" + ) + + +def test_existing_noise_still_stripped() -> None: + """Регрессия: старые хвосты (CSS/метро/«от N мин.») режутся как раньше.""" + assert _clean_address("ул. Токарей, 56к1Площадь 1905 года.css-39hgr0{fill:red}") == ( + "ул. Токарей, 56к1" + ) + assert _clean_address("ул. Малышева, 1.css-xxx{color:blue}") == "ул. Малышева, 1" + # #1773: слипшийся маркер района по-прежнему расклеивается. + assert _clean_address("ул. Евгения Савкова, 29р-н Академический") == ( + "ул. Евгения Савкова, 29, р-н Академический" + ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index 484403c3..3c3d717a 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -2097,8 +2097,16 @@ def _avito_room_label(name: str) -> str: _CSS_NOISE_RE = re.compile(r"\.?css-[a-z0-9_-]+\s*\{[^}]*\}", flags=re.I) +# Хвост «·<цифра>» — рейтинг дома и число отзывов, которые Авито с 27.07.2026 рендерит +# ВНУТРИ того же

в data-marker="item-location": «ул. Ткачей,17·5,0 · 4 отзыва». +# Прод 2026-08-10: 1 123 активных объявления с таким адресом, и у 1 123 из 1 123 нет +# координат (доля 100%) — ни один геокодер такую строку не берёт. Для сравнения, у +# объявлений с чистым адресом координаты есть у 4 766. +# Режем по «·», ЗА КОТОРОЙ ИДЁТ ЦИФРА (рейтинг «·4,9» либо счётчик «·2 отзыва»), а не +# по любой «·»: разделитель района у площадок пишется как «, 59 · р-н Академический» — +# за точкой буква, и такой хвост _deglue_house_marker намеренно сохраняет. _NOT_ADDRESS_TAIL_RE = re.compile( - r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+)", + r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I, ) From a227877905515b86e7a95bfd4de42ba3b2fa164f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 09:36:23 +0000 Subject: [PATCH 116/130] =?UTF-8?q?fix(tradein/scraper):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD,=20=D1=83=20=D0=BA=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=8B=D0=B9=20=D1=8F=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=8C,=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=91=D1=82=20=D0=B1=D1=8B=D1=82=D1=8C=20=D1=83=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D1=85=D0=BE=D0=BC=20(#2625)=20(#2813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrape_runs.py | 57 ++++++ .../tests/test_2625_run_that_did_nothing.py | 188 ++++++++++++++++++ .../src/scraper_kit/orchestration/runs.py | 57 ++++++ 3 files changed, 302 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index c83f178e..c7c887d5 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -179,6 +179,52 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non ) +def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: + """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625). + + Возвращает текст причины (для error) либо None, если прогон таким не является. + + Третий исход, у которого не было терминального статуса. Развёртка различает: + 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу → + `mark_banned` в самих sweep'ах (#2642, cian/yandex); + 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений → + `done` с нулём, это здоровый результат (в Серове реально 10 объявлений); + 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как + что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает + НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки), + и статуса ему никто не выдал — прогон уходил в `done`. + + Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров: + `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что + отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ + доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус + 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается + по умолчанию). + + Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала — + `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор). + + Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов + (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с, + таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5; + yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса). + НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной + пустоты (errors_count=0) — они остаются 'done'. + """ + anchors = _pick_int(counters, "anchors_total") + errors = _pick_int(counters, "errors_count") + if not anchors or anchors <= 0 or errors is None or errors < anchors: + return None + if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает + return None + return ( + f"sweep-honest-status: отказом кончились все {anchors} якорей прогона " + f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ " + f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или " + f"блокировкой площадки — статус 'failed' без диагноза (#2625)" + ) + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -445,7 +491,18 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки — иначе admin/observability показывает 0 (audit #1926). + + #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился + каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а + не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: + страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, + из-за которого задача и появилась. """ + did_nothing = _sweep_run_did_nothing(counters) + if did_nothing is not None: + logger.error("%s run_id=%d", did_nothing, run_id) + mark_failed(db, run_id, did_nothing, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( diff --git a/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py new file mode 100644 index 00000000..9dd3b3b0 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py @@ -0,0 +1,188 @@ +"""#2625: прогон, у которого отказом кончился каждый якорь, перестаёт быть 'done'. + +Задача заводилась про капчу Циана и пустые выдачи Яндекса. Основную её часть закрыл +#2642 (детект провала извлечения структуры → 'banned'). Замер на проде 2026-08-10 +подтверждает эффект симптоматически: развёртки cian/yandex, статус 'done' с нулём — +42 из 89 до деплоя #2642 (04.08 19:00 UTC) и 1 из 30 после. + +Этот единственный оставшийся — прогон 3557 (`yandex_city_sweep_pervouralsk`, +09.08 17:11): 155 миллисекунд, `{"anchors_total": 1, "anchors_done": 1, +"errors_count": 1, "lots_fetched": 0}`, статус 'done'. И он не новый: за 90 суток под +тот же признак попадают 28 прогонов, включая `yandex_city_sweep_nizhniy_tagil` — +16 суток подряд (15-30.07), каждый ровно 240 секунд (таймаут якоря), ноль лотов, +'done' каждый раз. + +Почему детект #2642 их не видит — и правильно не видит. Он считает попытки РАЗБОРА +(`_track_gate_result`), а transport_error туда НАМЕРЕННО не попадает (#2600 п.1: +«наш прокси сдох» не должен выглядеть баном площадки). Якорь, упавший по таймауту +или исключению до первого разобранного ответа, даёт `attempts == 0`, условие +`attempts > 0 and failures == attempts` молчит — и прогон уходит в 'done'. + +То есть третий исход существовал, но терминального статуса у него не было: + * площадка отбила → 'banned' (#2642, есть); + * площадка честно пуста → 'done' (есть, и это здоровый ответ); + * мы не дошли → ??? → 'done' ← дефект. + +Признак намеренно НЕ опирается на список антибот-маркеров: маркеры объясняют уже +случившийся отказ и молчат про неизвестный (урок 09.08, #2798). Здесь считается +собственная бухгалтерия прогона — `errors_count >= anchors_total` при измеренном +нуле. Что она доказывает: каждый якорь кончился отказом и собрано ноль. Чего НЕ +доказывает: кто виноват. Поэтому 'failed' без ban_kind, а не 'banned'/'platform'. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} + +# Реальные counters с прода (2026-08-10), не выдуманные. +PROD_3557_PERVOURALSK = { + "anchors_done": 1, + "errors_count": 1, + "lots_fetched": 0, + "lots_updated": 0, + "anchors_total": 1, + "lots_inserted": 0, + "address_failed": 0, + "combos_skipped": 0, + "address_enriched": 0, + "address_attempted": 0, + "price_history_rows": 0, +} +# Тот же source, прогон 3320 от 06.08 — отказ якоря БЫЛ (errors_count=1), но 117 лотов +# собрано. Частичный сбор — сбор, статус обязан остаться 'done'. +PROD_3320_PARTIAL = {**PROD_3557_PERVOURALSK, "lots_fetched": 117, "lots_inserted": 63} +# Прогон 2930 (cian_city_sweep_serov, 02.08): ноль лотов БЕЗ единого отказа — +# это либо честная пустота, либо недетект капчи; и то и другое — не наша тема, +# капча — предмет #2642, а честная пустота обязана оставаться 'done'. +PROD_2930_HONEST_EMPTY = {**PROD_3557_PERVOURALSK, "errors_count": 0} + + +def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]: + """Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов. + + Читаем СТАТУС В SQL, а не имя вызванной функции: тест должен краснеть на + поведении финализатора, а не на отсутствии нового имени в старом коде. + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + # sentry заглушаем: алерт-хуки best-effort и к предмету теста отношения не имеют. + with patch.object(mod, "sentry_sdk", MagicMock()): + mod.mark_done(db, 3557, dict(counters)) + return statuses + + +# ── 1. Три исхода разведены ────────────────────────────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_all_anchors_failed_zero_lots_is_not_done(name: str) -> None: + """Прод-прогон 3557: все якоря отказали, собрано 0 → 'failed', НЕ 'done'. + + Красный на старом коде: mark_done писал status='done'. + """ + assert _capture_status(_MODULES[name], PROD_3557_PERVOURALSK) == ["failed"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_partial_harvest_stays_done(name: str) -> None: + """Прод-прогон 3320: отказ якоря был, но 117 лотов собрано → остаётся 'done'.""" + assert _capture_status(_MODULES[name], PROD_3320_PARTIAL) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_honest_empty_stays_done(name: str) -> None: + """Ноль лотов без единого отказа — честная пустота, 'done' (ложной тревоги нет).""" + assert _capture_status(_MODULES[name], PROD_2930_HONEST_EMPTY) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_failed_run_carries_no_ban_diagnosis(name: str) -> None: + """Причина не установлена → ban_kind не пишется вовсе (#2764). + + 'banned' с диагнозом означал бы «нас забанила площадка» — а мы знаем только, + что якоря отказали. Ротацию IP (#2611) на догадке дёргать нельзя. + """ + mod = _MODULES[name] + sqls: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sqls.append(str(stmt)) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(mod, "sentry_sdk", MagicMock()): + mod.mark_done(db, 3557, dict(PROD_3557_PERVOURALSK)) + + assert not any("ban_kind" in s for s in sqls), "статус не должен нести диагноз" + assert any("status = 'failed'" in s for s in sqls) + + +# ── 2. Классификатор: границы, на которых легко получить ложную тревогу ────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize( + ("counters", "flagged", "why"), + [ + ({"anchors_total": 1, "errors_count": 1, "lots_fetched": 0}, True, "1 из 1 отказал"), + ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, True, "5 из 5 (avito ЕКБ)"), + ({"anchors_total": 5, "errors_count": 1, "lots_fetched": 0}, False, "1 из 5 — не все"), + ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 12}, False, "собрано 12"), + ({"anchors_total": 1, "errors_count": 0, "lots_fetched": 0}, False, "честная пустота"), + # full-load'ы пишут unique_fetched, а не lots_fetched — тот же смысл. + ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 0}, True, "full-load ноль"), + ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 340}, False, "full-load сбор"), + # Результат НЕ измерен — судить нечем, ноль не выдумывается (#2703). + ({"anchors_total": 1, "errors_count": 1}, False, "результата в counters нет"), + # Якорей нет вовсе: это не развёртка, чужой словарь счётчиков не трогаем. + ({"errors_count": 9, "lots_fetched": 0}, False, "не развёртка"), + ({"attempted": 5, "enriched": 0, "failed": 5}, False, "detail-backfill, чужой словарь"), + ({}, False, "пустые counters"), + ({"anchors_total": 0, "errors_count": 0, "lots_fetched": 0}, False, "нуль якорей"), + ], +) +def test_classifier_boundaries( + name: str, counters: dict[str, Any], flagged: bool, why: str +) -> None: + reason = _MODULES[name]._sweep_run_did_nothing(counters) + assert (reason is not None) is flagged, why + if flagged: + assert "#2625" in (reason or "") + + +# ── 3. Ровно те 16 суток, что прод прожил молча ────────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_sixteen_silent_tagil_runs_would_have_been_failed(name: str) -> None: + """15-30.07: 16 прогонов `yandex_city_sweep_nizhniy_tagil` по 240 с, 0 лотов. + + Каждый отчитался 'done'. Ни один алерт их не поднял: `_alert_if_consecutive_failures` + считает только failed/banned. С этой правкой все 16 — 'failed', то есть первый же + из них попадает в лестницу вех failed-стрика (#2670). + """ + tagil = {"anchors_total": 1, "anchors_done": 1, "errors_count": 1, "lots_fetched": 0} + statuses = [_capture_status(_MODULES[name], tagil) for _ in range(16)] + assert statuses == [["failed"]] * 16 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 1389e9b4..108a7891 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -174,6 +174,52 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non ) +def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: + """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625). + + Возвращает текст причины (для error) либо None, если прогон таким не является. + + Третий исход, у которого не было терминального статуса. Развёртка различает: + 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу → + `mark_banned` в самих sweep'ах (#2642, cian/yandex); + 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений → + `done` с нулём, это здоровый результат (в Серове реально 10 объявлений); + 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как + что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает + НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки), + и статуса ему никто не выдал — прогон уходил в `done`. + + Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров: + `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что + отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ + доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус + 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается + по умолчанию). + + Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала — + `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор). + + Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов + (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с, + таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5; + yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса). + НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной + пустоты (errors_count=0) — они остаются 'done'. + """ + anchors = _pick_int(counters, "anchors_total") + errors = _pick_int(counters, "errors_count") + if not anchors or anchors <= 0 or errors is None or errors < anchors: + return None + if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает + return None + return ( + f"sweep-honest-status: отказом кончились все {anchors} якорей прогона " + f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ " + f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или " + f"блокировкой площадки — статус 'failed' без диагноза (#2625)" + ) + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -510,7 +556,18 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки — иначе admin/observability показывает 0 (audit #1926). + + #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился + каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а + не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: + страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, + из-за которого задача и появилась. """ + did_nothing = _sweep_run_did_nothing(counters) + if did_nothing is not None: + logger.error("%s run_id=%d", did_nothing, run_id) + mark_failed(db, run_id, did_nothing, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( From 405d2f2eeccf3e44f636ac6f19ce19e8d891032b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 10:22:33 +0000 Subject: [PATCH 117/130] =?UTF-8?q?fix(tradein/db):=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=201123=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=BE=D0=B2=20=D0=90?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D0=BE=20=D1=81=20=D0=BF=D1=80=D0=B8=D0=BA?= =?UTF-8?q?=D0=BB=D0=B5=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC=20=D1=80=D0=B5=D0=B9?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=B3=D0=BE=D0=BC=20(#2814)=20(#2818)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...gs_backfill_avito_rating_glued_address.sql | 155 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + 2 files changed, 156 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql diff --git a/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql new file mode 100644 index 00000000..61120223 --- /dev/null +++ b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql @@ -0,0 +1,155 @@ +-- 254_listings_backfill_avito_rating_glued_address.sql +-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814). +-- +-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же

+-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4 +-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но +-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет +-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при +-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от +-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный +-- путь, которым старые строки могут стать чистыми. +-- +-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным): +-- +-- класс адреса (source='avito', is_active) строк с координатами +-- ------------------------------------------ ------ -------------- +-- чистый 7892 6111 (77.4%) +-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%) +-- NULL 360 0 (0.0%) +-- +-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался +-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE. +-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет. +-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов +-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается). +-- +-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь. +-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile( +-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I) +-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t") +-- → `return cleaned or None`. +-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace = +-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов +-- в trim, NULLIF(...,'') = `or None`. +-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА. +-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост +-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми +-- (проверено в dry-run: 296 до = 296 после). +-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после +-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений +-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем. +-- +-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с +-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере: +-- docker exec tradein-scraper python /tmp/m2814-parity.py +-- → rows=1123 mismatches=0 +-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде. +-- +-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10): +-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296 +-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17 +-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б +-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103 +-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5 +-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60 +-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2 +-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2 +-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов +-- → жилой район Сортировочный, мкр-н Старая +-- Сортировка, Кунарская ул.,14к2 +-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов +-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18 +-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес +-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то +-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты: +-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.) +-- +-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится. +-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в +-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) — +-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк +-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного. +-- Откат (idempotent, безопасен к повторному запуску): +-- +-- UPDATE listings +-- SET address = raw_payload->>'address' +-- WHERE source = 'avito' +-- AND raw_payload->>'address' ~ '·\s*\d' +-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM +-- regexp_replace(raw_payload->>'address', +-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), ''); +-- +-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не +-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно +-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123. +-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный, +-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская +-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО: +-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам +-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует +-- в порчу. +-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные. +-- +-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по +-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ +-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит +-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не +-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не +-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен: +-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до +-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк. +-- +-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции +-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в +-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL +-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь +-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже +-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход +-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200, +-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC. +-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит +-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245 +-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт +-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53% +-- успеха на адрес, гадать точнее не буду. +-- +-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО: +-- * не трогает COALESCE в апсерте — поведение осознанное (#2777); +-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем +-- свипе (замерено: пустых строк '' среди них 0, все именно NULL); +-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть +-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city), +-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у +-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это +-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь. +-- +-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql +-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE +-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет. +-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает +-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815. +-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт +-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та +-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт +-- громко (ON_ERROR_STOP=on), чем встанет тихо. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +UPDATE listings +SET address = NULLIF( + trim(both E' ,.\n\t' FROM + regexp_replace( + address, + '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', + '', + 'i' + )), + ''), + geocode_tried_at = NULL +WHERE source = 'avito' + AND address ~ '·\s*\d'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 80028a39..71642631 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -244,3 +244,4 @@ 240_trade_in_estimates_retain_until.sql 250_drop_duplicate_expires_at_index.sql 251_listings_drop_ceiling_height.sql +254_listings_backfill_avito_rating_glued_address.sql From 1307d55da6431d69e6330a98adf485b502aadef5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 10:34:39 +0000 Subject: [PATCH 118/130] =?UTF-8?q?fix(site-finder):=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=D0=B2=D1=8B=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=D0=B0,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20(#2811)=20(#?= =?UTF-8?q?2817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 25 +++++-- .../services/site_finder/weight_profiles.py | 58 ++++++++++++--- .../api/v1/test_analyze_inline_weights.py | 74 +++++++++++++++++++ backend/tests/test_weight_profiles.py | 68 +++++++++++++++-- 4 files changed, 200 insertions(+), 25 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 74bfb5c9..191ed5c4 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2189,12 +2189,21 @@ def analyze_parcel( _effective_weights = {**_POI_WEIGHTS, **_inline_weights} _weights_source = "inline" else: - _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) - _weights_source = ( - "profile" - if profile_id is not None - else ("user_default" if profile_user_id is not None else "system") - ) + # Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811): + # profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и + # тогда веса системные или дефолтные, а не профильные. + _resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) + _effective_weights = _resolved.weights + _weights_source = _resolved.source + + # «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит, + # был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" — + # иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли». + # None когда profile_id не передавали; False когда передали, но применилось + # другое (не найден / чужой / перебит inline-весами). + _requested_profile_applied: bool | None = ( + None if profile_id is None else _weights_source == "profile" + ) # 4) Scoring: weighted sum с distance decay score = 0.0 @@ -4085,9 +4094,12 @@ def analyze_parcel( # (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри. "program_alternatives": program_alternatives, # #114/#201: кастомные веса POI — source + applied dict для прозрачности. + # source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли + # удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали. "weights_profile": { "source": _weights_source, "profile_id": profile_id, + "requested_profile_applied": _requested_profile_applied, "user_id": profile_user_id, "weights_applied": _effective_weights, "inline_weights": _inline_weights, @@ -4203,6 +4215,7 @@ def analyze_parcel( "profile_user_id": profile_user_id, "inline_weights": _inline_weights, "weights_source": _weights_source, + "requested_profile_applied": _requested_profile_applied, "x_session_id": _session_id, }, district=_district_name, diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 08a253d1..7639c02d 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -10,7 +10,7 @@ API surface: - create_profile(db, payload) → WeightProfile - update_profile(db, user_id, profile_id, payload) → WeightProfile | None - delete_profile(db, user_id, profile_id) → bool -- resolve_weights(db, user_id, profile_id) → dict[str, float] +- resolve_weights(db, user_id, profile_id) → ResolvedWeights(weights, source) """ from __future__ import annotations @@ -19,7 +19,7 @@ import json import logging import math from datetime import datetime -from typing import Any +from typing import Any, NamedTuple from pydantic import BaseModel, Field, field_validator from sqlalchemy import text @@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool: return True -def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]: - """Вернуть эффективные веса для analyze_parcel. +class ResolvedWeights(NamedTuple): + """Веса + КАКОЙ источник фактически применился (#2811). + + Лестница приоритетов ниже по построению стирает разницу между «взял, что + просили» и «не нашёл, взял что было» — а метка в ответе /analyze строится + именно на этой разнице. Поэтому источник возвращается вместе с весами, а не + выводится вызывающим из своих же входных параметров. NamedTuple, а не голый + dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко, + молча «весами» этот объект не притворится. + """ + + weights: dict[str, float] + source: str # "profile" | "user_default" | "system" + + +def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights: + """Вернуть эффективные веса для analyze_parcel + фактический их источник. Порядок приоритетов: - 1. profile_id задан → загрузить именно этот профиль - 2. user_id задан → загрузить default-профиль пользователя - 3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS + 1. profile_id задан → загрузить именно этот профиль → source="profile" + 2. user_id задан → загрузить default-профиль пользователя → source="user_default" + 3. Иначе → системные значения _SYSTEM_POI_WEIGHTS → source="system" + + Запрошенный, но НЕ применённый profile_id — не тишина: warning с + идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для + /analyze — необязательный модификатор, а не адресуемый ресурс; 404 превратил + бы гонку «профиль удалили между списком и анализом» в отказ вместо честно + помеченного ответа. Клиенту хватает source + requested_profile_not_found. """ if profile_id is not None and user_id is not None: profile = get_profile(db, user_id, profile_id) @@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic logger.debug( "resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id ) - return dict(profile.weights) + return ResolvedWeights(dict(profile.weights), "profile") + resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system") if user_id is not None: profile = get_default_profile(db, user_id) if profile is not None and profile.weights: - logger.debug("resolve_weights: user=%s → default profile weights", user_id) - return dict(profile.weights) + resolved = ResolvedWeights(dict(profile.weights), "user_default") - logger.debug("resolve_weights: returning system defaults") - return dict(_SYSTEM_POI_WEIGHTS) + if profile_id is not None: + # Сюда попадаем, если запрошенный профиль не применился: owner не передан + # (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights + # пустые. Раньше это был logger.debug, которого на проде нет, — и оценка + # молча считалась не по тем весам (#2811, ранее #2788). + logger.warning( + "resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — " + "фактический источник весов %r", + profile_id, + user_id, + resolved.source, + ) + else: + logger.debug("resolve_weights: источник весов %s", resolved.source) + return resolved diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index ad7a2248..cc61f0ef 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None: finally: app.dependency_overrides.clear() _stop_patches() + + +def test_analyze_missing_profile_is_not_labelled_profile() -> None: + """#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'. + + Три способа промахнуться мимо профиля (все три воспроизведены живым запросом + на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id. + В mock-БД профилей нет — значит применились системные веса, и ответ обязан + это признать, а не утверждать, что считал по профилю. + """ + from app.core.db import get_db + from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS + + for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"): + db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}") + assert resp.status_code == 200, resp.text + wp = resp.json()["weights_profile"] + # sanity: веса и правда системные, промах реальный + assert wp["weights_applied"]["tram_stop"] == pytest.approx( + _SYSTEM_POI_WEIGHTS["tram_stop"] + ) + assert wp["source"] != "profile", ( + f"?{qs}: применились системные веса, а метка source='profile' — " + "ответ утверждает то, чего не было (#2811)" + ) + assert wp["source"] == "system" + # «что просили» не теряется: запрошенный id + явный признак промаха + assert wp["profile_id"] == 999999 + assert wp["requested_profile_applied"] is False + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_found_profile_keeps_label_and_flag() -> None: + """Обратная сторона: профиль найден → source='profile', флаг промаха False.""" + from datetime import UTC, datetime + + import app.services.site_finder.weight_profiles as wp_module + from app.core.db import get_db + from app.services.site_finder.weight_profiles import WeightProfile + + profile = WeightProfile( + id=7, + user_id="user-1", + profile_name="test", + weights={"tram_stop": -0.4}, + is_default=False, + description=None, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + original = wp_module.get_profile + wp_module.get_profile = lambda _db, uid, pid: profile + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1") + assert resp.status_code == 200, resp.text + wp = resp.json()["weights_profile"] + assert wp["source"] == "profile" + assert wp["requested_profile_applied"] is True + assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4) + finally: + wp_module.get_profile = original + app.dependency_overrides.clear() + _stop_patches() diff --git a/backend/tests/test_weight_profiles.py b/backend/tests/test_weight_profiles.py index 9cbb97c0..87b62883 100644 --- a/backend/tests/test_weight_profiles.py +++ b/backend/tests/test_weight_profiles.py @@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет: - resolve_weights: нет user_id и profile_id → системные дефолты - resolve_weights: user_id задан, default-профиль есть → его веса - resolve_weights: profile_id задан → его веса -- resolve_weights: профиль не найден → системные дефолты (fallback) +- resolve_weights: профиль не найден → системные дефолты (fallback) + source != profile """ from __future__ import annotations +import logging from unittest.mock import MagicMock import pytest @@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None: """Оба аргумента None → возвращаются системные веса.""" db = MagicMock() result = resolve_weights(db, user_id=None, profile_id=None) - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + assert result.source == "system" # db не должен вызываться вообще db.execute.assert_not_called() @@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None: """Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS.""" db = MagicMock() result = resolve_weights(db, user_id=None, profile_id=None) - result["school"] = 999.0 + result.weights["school"] = 999.0 # Оригинал не изменён assert _SYSTEM_POI_WEIGHTS["school"] == 1.5 @@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None: finally: wp_module.get_default_profile = original - assert result == custom_weights + assert result.weights == custom_weights + assert result.source == "user_default" def test_resolve_weights_uses_specific_profile() -> None: @@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None: finally: wp_module.get_profile = original - assert result == custom_weights + assert result.weights == custom_weights + assert result.source == "profile" def test_resolve_weights_profile_not_found_fallback() -> None: @@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None: wp_module.get_profile = original_get wp_module.get_default_profile = original_default - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + # #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли + assert result.source == "system" def test_resolve_weights_empty_profile_weights_fallback() -> None: @@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None: finally: wp_module.get_default_profile = original_default - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + assert result.source == "system" + + +def test_resolve_weights_profile_id_without_owner_is_not_profile( + caplog: pytest.LogCaptureFixture, +) -> None: + """#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется. + + Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 — + source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он + -0.4). Метка обязана быть 'system', а промах — попасть в warning. + """ + db = MagicMock() + with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"): + result = resolve_weights(db, user_id=None, profile_id=1) + + assert result.source == "system" + assert result.weights == _SYSTEM_POI_WEIGHTS + assert "profile_id=1" in caplog.text + db.execute.assert_not_called() # профиль даже не искали + + +def test_resolve_weights_missing_profile_falls_to_user_default_not_profile( + caplog: pytest.LogCaptureFixture, +) -> None: + """#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль. + + Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не + видна. Метка должна сказать 'user_default', а не 'profile'. + """ + import app.services.site_finder.weight_profiles as wp_module + + default_profile = _make_profile_mock({"school": 2.0}) + db = MagicMock() + original_get = wp_module.get_profile + original_default = wp_module.get_default_profile + wp_module.get_profile = lambda _db, uid, pid: None + wp_module.get_default_profile = lambda _db, uid: default_profile + try: + with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"): + result = resolve_weights(db, user_id="user-1", profile_id=999) + finally: + wp_module.get_profile = original_get + wp_module.get_default_profile = original_default + + assert result.source == "user_default" + assert result.weights == {"school": 2.0} + assert "profile_id=999" in caplog.text From 0ed0140c9e81da0b307488af388196739d6bc3d6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 11:18:19 +0000 Subject: [PATCH 119/130] =?UTF-8?q?fix(tradein/dedup):=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=BE=D0=BA=20=D1=81=D1=85=D0=BB=D0=BE=D0=BF?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BC=D0=BE?= =?UTF-8?q?=D0=B2=20=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D1=8F=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=D0=BC=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=D0=BC,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=BE=D0=B9=20(#2?= =?UTF-8?q?690)=20(#2820)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/house_dedup_merge.py | 229 +++++++++++++++--- .../backend/tests/test_house_dedup_merge.py | 90 +++++++ 2 files changed, 290 insertions(+), 29 deletions(-) diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index 0f244b97..511386a1 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -13,8 +13,10 @@ WHAT this is: pipeline, run inside ONE transaction so a crash leaves the table untouched. Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address - COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod — - confirmed in migration 040 — so address is the real building key). The clean source matters: + COALESCE(short_address, full_address, address) — the address is the only building key we + have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on + prod», is no longer true — 2 648 of 9 179 rows carry one — and the conclusion no longer + rests on it). The clean source matters: `address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр» → canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66» (→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon @@ -116,6 +118,43 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230): asymmetry — merge allowed without a proximity check — was invisible in data before; now «how many merges happened beyond N metres, on which key» is one query. +KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690): + #2690 asked for a cluster key that does not come from the normalized address, so that two + rows merge on two independent statements of identity rather than one restated twice. Every + field `houses` carries was checked against the live table. None qualifies: + + cadastral_number 2 648 filled, ALL 2 648 values DISTINCT → collapses nothing. Provenance: + all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are + DaData's answer to our address string, not a second observation of the + building. (The other cadastre we hold, listings.building_cadastral_number, + is the KNN geo-nearest hint — 20.1% of its values cover >1 ГАР building; + #2674 refused it as an identity key and that stands.) + house_fias_id 3 678 filled, ALL DISTINCT → the FIAS pass merges 0 rows today. Same + DaData provenance. + gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share + the canon (the guid restates it), 17 do not — and 5 of those 17 are + >250 m apart, worst 5 064 km. Still circular, still noisy. + zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the + loader sets it WHERE gar_house_guid = , i.e. it IS the ГАР guid for + 4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback + — keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon, + 193 come through the fallback, and 30 of the 31 pairs >250 m apart do too. + source+ext_house_id, cian_internal_house_id, yandex_jk_id + distinct by construction / 39 / 0 rows → nothing to cluster. + coordinates a real independent observation, but not an IDENTITY: neighbours share a + yard. It is already used the only way it can be — as the guard. + year_built+total_floors + a FALSE witness, not a corroborator: of the 391 same-canon pairs the + guard cannot judge, only 18 agree on both fields (357 have a NULL), while + 306 pairs the guard rejected at >250 m DO agree — it would confirm merges + that are provably wrong. + + Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the + canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the + residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the + guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart + (median 1 084 m) — those are not duplicates at all, the canon key is wrong about them. + IDEMPOTENCY: Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the mapping is empty → every statement touches 0 rows → no-op. Re-running is safe. @@ -165,6 +204,14 @@ _COMPLETENESS_EXPR = """ # правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую # корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома / # этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно. +# +# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле — +# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина +# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния). +# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого +# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить +# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом +# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей». _KEEPER_ORDER = f""" (h.geom IS NOT NULL) DESC, listing_cnt DESC NULLS LAST, @@ -199,37 +246,17 @@ _CANON_KEY_EXPR = """ """ -def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: - """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. +def _ranked_cte(cluster_key_case: str) -> str: + """Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row. - Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers - are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered - row shares one fias by construction). - - apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. - - CANON pass → True: the canon strips город/район, so same-street-number buildings in - different region-66 towns share a canon; the guard stops the cross-town over-merge. - - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks - proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the - geom-first keeper rule simultaneously repairs the broken coordinate). + Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census + (`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census + built from its own copy of the clustering would drift from the pass it describes and the + drift would be invisible (it is the same class of error as #2690's cluster key: two + expressions that look alike and are not). `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection. """ - geo_guard = ( - """ - -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two - -- different buildings sharing a street+number canon («Ленина 5» in different region-66 - -- towns) collapse to one cluster_key. A loser merges only when geographically next to the - -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at - -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL - -- geom on either side, → left as separate rows (conservative — never over-merges). - AND keeper_geom IS NOT NULL - AND loser_geom IS NOT NULL - AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250""" - if apply_geo_guard - else "" - ) return f""" - CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS WITH clustered AS ( SELECT id, @@ -281,7 +308,41 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: FROM dup_houses dh JOIN houses h ON h.id = dh.id LEFT JOIN listing_counts lc ON lc.house_id = dh.id + )""" + + +def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: + """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. + + Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers + are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered + row shares one fias by construction). + + apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. + - CANON pass → True: the canon strips город/район, so same-street-number buildings in + different region-66 towns share a canon; the guard stops the cross-town over-merge. + - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks + proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the + geom-first keeper rule simultaneously repairs the broken coordinate). + `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection. + """ + geo_guard = ( + """ + -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two + -- different buildings sharing a street+number canon («Ленина 5» in different region-66 + -- towns) collapse to one cluster_key. A loser merges only when geographically next to the + -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at + -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL + -- geom on either side, → left as separate rows (conservative — never over-merges). + AND keeper_geom IS NOT NULL + AND loser_geom IS NOT NULL + AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250""" + if apply_geo_guard + else "" ) + return f""" + CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS + {_ranked_cte(cluster_key_case)} -- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but -- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon -- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per @@ -314,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR)) # merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords). _BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False)) +# ── RESIDUAL CENSUS (#2690 п.2/п.4) ─────────────────────────────────────────── +# +# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY. +# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered +# out is counted here, bucketed by the reason it survived. +# +# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there +# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a +# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the +# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821. +# +# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard +# rejected» are opposite facts: +# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN. +# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are +# NOT duplicates — the canon key is wrong about them (prod 2026-08-10: +# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt. +# residual_cross_fias — provably different buildings (two different ФИАС UUIDs). +# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real +# run; non-zero is a tripwire on the pass itself, not a census entry. +# residual_listings is the user-visible size of the remainder (listings hanging on those rows). +_RESIDUAL_SQL = text( + f""" + {_ranked_cte(_CANON_KEY_EXPR)} + SELECT + count(*) FILTER (WHERE rn > 1) AS residual_rows, + COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings, + count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL) + AS residual_no_geom, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250) + AS residual_mergeable + FROM ( + SELECT rn, + COALESCE(lc.listing_cnt, 0) AS lcnt, + CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL + THEN ST_DistanceSphere(loser_geom, keeper_geom) + END AS dist, + (NULLIF(loser_fias, '') IS NOT NULL + AND NULLIF(keeper_fias, '') IS NOT NULL + AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias + FROM ranked + LEFT JOIN listing_counts lc ON lc.house_id = ranked.id + ) r + """ +) + # Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op. _STEPS: list[tuple[str, str]] = [ # ── Plain re-point (no UNIQUE on the FK column) ─────────────────────────── @@ -726,6 +835,15 @@ class DedupMergeResult: listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children children_repointed: int = 0 # survivor child rows moved loser→keeper + # Residual census (#2690): same-canon rows STILL in the table after this run, by reason. + # Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with, + # so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL. + residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets) + residual_listings: int = 0 # listings hanging on them (the user-visible size) + residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates + residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building + residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings + residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0 dry_run: bool = False duration_sec: float = field(default=0.0) @@ -736,6 +854,12 @@ class DedupMergeResult: "listings_repointed": self.listings_repointed, "children_deleted": self.children_deleted, "children_repointed": self.children_repointed, + "residual_rows": self.residual_rows, + "residual_listings": self.residual_listings, + "residual_no_geom": self.residual_no_geom, + "residual_far": self.residual_far, + "residual_cross_fias": self.residual_cross_fias, + "residual_mergeable": self.residual_mergeable, "dry_run": int(self.dry_run), "duration_sec": int(self.duration_sec), } @@ -853,6 +977,49 @@ def _run_merge_pass( db.execute(_BACKFILL_ALIASES_SQL) +def _measure_residual(db: Session, result: DedupMergeResult) -> None: + """Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690). + + Read-only; runs after both passes, so it describes the table as the run leaves it (under + dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of + `_run_merge_pass` because the census is about the CANON key only and must be taken once per + call, not once per pass. + + Never fails the merge: the merge itself is the product, the census is instrumentation, and a + census that can abort a committed-by-now transaction would be worse than a missing number. + """ + try: + rows = db.execute(_RESIDUAL_SQL).all() + except Exception: + logger.exception("merge_duplicate_houses: residual census failed — counters left at 0") + return + if not rows: + return + r = rows[0] + result.residual_rows = int(r.residual_rows or 0) + result.residual_listings = int(r.residual_listings or 0) + result.residual_no_geom = int(r.residual_no_geom or 0) + result.residual_far = int(r.residual_far or 0) + result.residual_cross_fias = int(r.residual_cross_fias or 0) + result.residual_mergeable = int(r.residual_mergeable or 0) + logger.info( + "merge_duplicate_houses: residual rows=%d listings=%d " + "(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)", + result.residual_rows, + result.residual_listings, + result.residual_no_geom, + result.residual_far, + result.residual_cross_fias, + result.residual_mergeable, + ) + if result.residual_mergeable: + logger.warning( + "merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass " + "left work on the table (expected 0)", + result.residual_mergeable, + ) + + def merge_duplicate_houses( db: Session, *, @@ -908,6 +1075,10 @@ def merge_duplicate_houses( result=result, ) + # Census of what is LEFT (read-only). Runs before the no-op early return on purpose: + # a run that merged nothing is exactly the run whose remainder needs a number. + _measure_residual(db, result) + if result.losers_deleted == 0: # Clean table — both passes empty. Roll back (we only opened temp tables). db.rollback() diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index 63db7822..4355e323 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -18,6 +18,7 @@ import inspect import os import re from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -486,9 +487,13 @@ class _FakeDB: mapping_rows: list[_Row], step_rowcount: int = 1, fk_children: dict[str, str] | None = None, + residual: dict[str, int] | None = None, + residual_raises: bool = False, ): self._mapping_rows = mapping_rows self._step_rowcount = step_rowcount + self._residual = residual + self._residual_raises = residual_raises self._mapping_served = False # The catalog the FK-child guard reads; defaults to the real live set. self._fk_children = _FK_CHILDREN if fk_children is None else fk_children @@ -503,6 +508,12 @@ class _FakeDB: return _FakeResult() if "FROM pg_constraint" in sql: return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()]) + if "AS residual_rows" in sql: # residual census (#2690) — read-only, after both passes + if self._residual_raises: + raise RuntimeError("census exploded") + if self._residual is None: + return _FakeResult(rows=[]) + return _FakeResult(rows=[SimpleNamespace(**self._residual)]) if "SELECT loser_id, keeper_id, norm_address" in sql: # The service now runs TWO passes (fias, then canon). Model «fias pass found the # duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards. @@ -550,6 +561,85 @@ def test_dry_run_computes_counts_but_rolls_back() -> None: assert db.rollbacks == 1 +# ── Residual census (#2690 п.2/п.4): остаток фиксируется числом, а не оценкой ── + + +def test_residual_census_reuses_the_canon_mapping_prelude() -> None: + """Перепись остатка обязана считать РОВНО те строки, о которых рассуждает канон-проход. + + Общий `_ranked_cte` — не косметика: собственная копия кластеризации разъехалась бы с + проходом, который она описывает, и разъезд был бы невидим (это тот же класс ошибки, что и + ключ в #2690 — два выражения, похожие друг на друга и не равные). RED до фикса: переписи + нет вовсе. + """ + residual = _flat(str(hdm._RESIDUAL_SQL.text)) + prelude = _flat(hdm._ranked_cte(hdm._CANON_KEY_EXPR)) + assert prelude in residual + assert prelude in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + + +def test_residual_census_keeps_silent_guard_apart_from_rejecting_guard() -> None: + """«Координат нет» и «дальше 250 м» — противоположные факты, в одну сумму их нельзя. + + Первое означает, что страж не смог высказаться (остаток неизвестен), второе — что он + высказался по существу и дома РАЗНЫЕ (это вообще не дубли). Сумма из этих двух читается как + «долг», которого нет. Плюс: перепись НЕ применяет гео-фильтр — иначе она считала бы только + то, что и так слилось. + """ + residual = _flat(str(hdm._RESIDUAL_SQL.text)) + for bucket in ( + "AS residual_rows", + "AS residual_listings", + "AS residual_no_geom", + "AS residual_far", + "AS residual_cross_fias", + "AS residual_mergeable", + ): + assert bucket in residual, bucket + # Гео-страж 250 м здесь — РАЗДЕЛИТЕЛЬ корзин, а не фильтр строк. + assert "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in residual + assert set(hdm.DedupMergeResult().to_counters()) >= { + "residual_rows", + "residual_listings", + "residual_no_geom", + "residual_far", + "residual_cross_fias", + "residual_mergeable", + } + + +def test_residual_counters_reported_even_when_nothing_merged() -> None: + """Прогон, который не слил ничего, — ровно тот, чьему остатку нужно число.""" + db = _FakeDB( + mapping_rows=[], + residual={ + "residual_rows": 963, + "residual_listings": 1765, + "residual_no_geom": 326, + "residual_far": 568, + "residual_cross_fias": 8, + "residual_mergeable": 61, + }, + ) + out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert out["losers_deleted"] == 0 + assert out["residual_rows"] == 963 + assert out["residual_listings"] == 1765 + assert out["residual_no_geom"] == 326 + assert out["residual_far"] == 568 + assert out["residual_cross_fias"] == 8 + assert out["residual_mergeable"] == 61 + + +def test_residual_census_failure_never_breaks_the_merge() -> None: + """Перепись — приборы, слияние — продукт: упавший счётчик не отменяет коммит.""" + db = _FakeDB(mapping_rows=[_Row(2, 1, "ул. мира, 10")], residual_raises=True) + out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert out["losers_deleted"] == 1 + assert out["residual_rows"] == 0 # неизвестно — и это видно, а не выдумано + assert db.commits == 1 + + def test_real_merge_commits() -> None: """dry_run=False with dups → exactly one commit, no rollback.""" rows = [_Row(2, 1, "ул. мира, 10")] From 20ec6a5d33cd83d04510456ee06708aa62cf01e1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 11:29:19 +0000 Subject: [PATCH 120/130] =?UTF-8?q?fix(tradein/cian):=20403=20=D1=81=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D1=83=D0=B7=D0=B5=D0=BB=20?= =?UTF-8?q?=D1=81=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B3=D0=B0=D1=81=D0=BD=D0=B5=D1=82=20=D0=B2=20?= =?UTF-8?q?return=20None=20(#2700)=20(#2821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 9 +- .../backend/app/services/scrape_runs.py | 64 ++++++ .../tests/test_2700_cian_detail_403_node.py | 208 ++++++++++++++++++ .../src/scraper_kit/cian_exceptions.py | 34 +++ .../src/scraper_kit/orchestration/runs.py | 64 ++++++ .../src/scraper_kit/providers/cian/detail.py | 30 +++ 6 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 856a7213..07a08672 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -1904,9 +1904,16 @@ async def scrape_cian_detail( Without it → debug-only (no DB write). """ _assert_allowed_url(offer_url) + from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment - enrichment = await fetch_detail(offer_url, config=RealScraperConfig()) + try: + enrichment = await fetch_detail(offer_url, config=RealScraperConfig()) + except CianBlockedError as exc: + # #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке + # нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с + # этого узла» — разные новости для того, кто дёргает ручку руками. + raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc if enrichment is None: raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}") diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index c7c887d5..d1471ebf 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -225,6 +225,61 @@ def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: ) +# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило. +# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд +# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу +# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90 +# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не +# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд. +_PHASE_MIN_ATTEMPTS = 3 + + +def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: + """Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None. + + Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит + случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на + сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ. + + Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50, + detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала + HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового + прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ + подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`). + + Признак — собственная бухгалтерия фазы: `_failed == _attempted` при + `attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ + `X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это + ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя + настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv. + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'. + + Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из + 3 292 'done' (1.6%) — 33 cian_city_sweep* (detail 26, houses 15 — часть прогонов + попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал). + Остальные 3 240 остаются 'done'. + """ + for key in sorted(counters): + if not key.endswith("_attempted"): + continue + phase = key[: -len("_attempted")] + attempted = _pick_int(counters, key) + failed = _pick_int(counters, f"{phase}_failed") + if attempted is None or failed is None: + continue + if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted: + return ( + f"phase-honest-status: фаза '{phase}' отказала полностью — " + f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы " + f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. " + f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус " + f"'failed' без диагноза (#2700)" + ) + return None + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -497,12 +552,21 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, из-за которого задача и появилась. + + #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка + целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано + ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет». """ did_nothing = _sweep_run_did_nothing(counters) if did_nothing is not None: logger.error("%s run_id=%d", did_nothing, run_id) mark_failed(db, run_id, did_nothing, counters) return + phase_dead = _phase_totally_failed(counters) + if phase_dead is not None: + logger.error("%s run_id=%d", phase_dead, run_id) + mark_failed(db, run_id, phase_dead, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( diff --git a/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py new file mode 100644 index 00000000..d6637486 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py @@ -0,0 +1,208 @@ +"""#2700: 403 Циана перестаёт умирать внутри провайдера — узел снимается, прогон честен. + +Живая различающая проба на проде 2026-08-10 (один и тот же detail-URL, один и тот же +код, менялся ТОЛЬКО прокси-узел): + + узел 1 (asocks-residential-1, 46.8.110.92) → HTTP 403, 21 564 б, `cian_waf_block` + узел 9 (asocks-mobile-1, 5.227.16.0) → HTTP 200, 617 352 б, state ok + узел 10 (asocks-mobile-2, 95.104.183.29) → HTTP 200, 617 355 б, state ok + узел 11 (asocks-mobile-3, 95.55.49.98) → HTTP 200, 617 407 б, state ok + +То есть отбита была ПАРА «узел × Циан», а не площадка (и не наши заголовки: те же 200 +пришли без единой куки — detail-страница авторизации не требует). Пятнадцать суток +подряд это выглядело как «Циан нас забанил» ровно потому, что 403 гасился в +`return None`: пул получал `mark_health(ok=True)` на отбитый узел и продолжал выдавать +его Циану, а прогон писал `detail_failed=50` при `errors_count=0` и статусе `done`. + +Тесты красные на старом коде: + * `fetch_detail` возвращал None и НЕ звал `mark_banned` → узел оставался в выдаче; + * `mark_done` писал `status='done'` прогону, у которого отказали все 50 попыток. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.cian_exceptions import CianBlockedError +from scraper_kit.contracts import ProxyLease +from scraper_kit.orchestration import runs as kit_runs +from scraper_kit.providers.cian import detail as cian_detail +from scraper_kit.proxy_errors import ProxyBanError + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} +_LEASE = ProxyLease(id=1, url="http://user:pass@node-1:10423", kind="http", rotate_url=None) + + +@dataclass +class _FakeConfig: + use_proxy_pool_curl: bool = True + cian_proxy_url: str | None = None + environment: str = "production" + + +class _SpyProvider: + """ProxyProvider-заглушка (тот же контракт, что в test_proxy_pool_curl_paths).""" + + def __init__(self) -> None: + self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] + self.release_calls: list[int] = [] + + def acquire(self, provider: str) -> ProxyLease: + return _LEASE + + def release(self, lease: ProxyLease) -> None: + self.release_calls.append(lease.id) + + def mark_health( + self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None + ) -> None: + self.mark_health_calls.append((lease.id, ok)) + + def mark_banned(self, lease: ProxyLease, *, source: str) -> None: + self.mark_banned_calls.append((lease.id, source)) + + +def _session_returning(status_code: int, text: str = "") -> MagicMock: + session = MagicMock() + session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text)) + session.close = AsyncMock() + return session + + +async def _fetch(status_code: int, spy: _SpyProvider) -> Any: + with patch.object( + cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code) + ): + return await cian_detail.fetch_detail( + "https://ekb.cian.ru/sale/flat/332775238/", + config=_FakeConfig(), + proxy_provider=spy, + ) + + +# ── 1. 403 доходит до пула ──────────────────────────────────────────────────── + + +async def test_403_bans_the_node_for_cian_only() -> None: + """Красный на старом коде: было `return None`, бана узла не происходило. + + Проверяется ПОВЕДЕНИЕ пула (`mark_banned` на паре «узел × cian»), а не наличие + нового имени в коде. + """ + spy = _SpyProvider() + with pytest.raises(CianBlockedError): + await _fetch(403, spy) + assert spy.mark_banned_calls == [(1, "cian")] + assert spy.mark_health_calls == [(1, False)] + assert spy.release_calls == [1] # lease не течёт даже на бане + + +def test_blocked_error_is_recognised_by_generic_proxy_layer() -> None: + """Generic curl-слой узнаёт бан по `ProxyBanError`, не зная про Циан.""" + assert issubclass(CianBlockedError, ProxyBanError) + + +# ── 2. Не-бан остаётся не-баном ─────────────────────────────────────────────── + + +async def test_404_does_not_ban_the_node() -> None: + """Снятое объявление — не бан: наказывать за него здоровый узел нельзя.""" + spy = _SpyProvider() + assert await _fetch(404, spy) is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(1, True)] + + +# ── 3. Прогон с полностью отказавшей фазой перестаёт быть 'done' ────────────── + + +def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]: + """Статусы всех UPDATE'ов, которые сделал mark_done на фейковой сессии. + + Читаем СТАТУС В SQL (как в test_2625_run_that_did_nothing), а не имя вызванной + функции: тест обязан краснеть на поведении финализатора. + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(mod, "sentry_sdk", MagicMock()): + mod.mark_done(db, 3258, dict(counters)) + return statuses + + +# Реальные counters с прода, не выдуманные. +# Прогон 3258 (cian_city_sweep, 06.08): 50 из 50 detail'ов отказали, статус 'done'. +PROD_3258_ALL_DETAIL_FAILED = { + "anchors_done": 5, + "anchors_total": 5, + "errors_count": 0, + "lots_fetched": 1680, + "lots_inserted": 59, + "lots_updated": 626, + "detail_attempted": 50, + "detail_failed": 50, + "detail_enriched": 0, + "houses_attempted": 40, + "houses_failed": 40, + "houses_enriched": 0, +} +# Прогон 3597 (cian_city_sweep, 10.08, уже после того как 403 ушёл): 10 из 11 удачны. +PROD_3597_MOSTLY_OK = { + **PROD_3258_ALL_DETAIL_FAILED, + "errors_count": 1, + "detail_attempted": 11, + "detail_failed": 1, + "detail_enriched": 10, + "houses_attempted": 0, + "houses_failed": 0, +} +# Одиночная попытка, и та неудачна — шум, а не диагноз: прогон остаётся 'done'. +SINGLE_ATTEMPT_FAILED = { + **PROD_3597_MOSTLY_OK, + "detail_attempted": 1, + "detail_failed": 1, + "detail_enriched": 0, +} + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_phase_failed_100_percent_is_not_done(name: str) -> None: + """Прод-прогон 3258: detail 50/50 отказ → 'failed'. Красный на старом коде.""" + assert _capture_status(_MODULES[name], PROD_3258_ALL_DETAIL_FAILED) == ["failed"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_partial_phase_failure_stays_done(name: str) -> None: + """Прод-прогон 3597: 1 отказ из 11 → остаётся 'done' (частичный отказ — не отказ).""" + assert _capture_status(_MODULES[name], PROD_3597_MOSTLY_OK) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_single_failed_attempt_stays_done(name: str) -> None: + """Порог _PHASE_MIN_ATTEMPTS: одна неудачная попытка прогон не роняет.""" + assert _capture_status(_MODULES[name], SINGLE_ATTEMPT_FAILED) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_attempts_stays_done(name: str) -> None: + """Фаза не запускалась (0 попыток) — 0 == 0 не должно читаться как отказ.""" + counters = {**PROD_3597_MOSTLY_OK, "detail_attempted": 0, "detail_failed": 0} + assert _capture_status(_MODULES[name], counters) == ["done"] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py new file mode 100644 index 00000000..e1e38ab5 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_exceptions.py @@ -0,0 +1,34 @@ +"""Cian-specific exceptions для anti-bot detection.""" + +from scraper_kit.proxy_errors import ProxyBanError + + +class CianBlockedError(ProxyBanError): + """HTTP 403 от Циана — узел, с которого мы пришли, отбит WAF'ом площадки. + + Живая различающая проба 2026-08-10 (#2700), один и тот же detail-URL, один и тот + же код, менялся ТОЛЬКО прокси-узел: + + узел 1 (asocks-residential, 46.8.110.92) → HTTP 403, 21 564 байт, + маркер `cian_waf_block` + узел 9 (asocks-mobile-1, 5.227.16.0) → HTTP 200, 617 352 байт, state ok + узел 10 (asocks-mobile-2, 95.104.183.29) → HTTP 200, 617 355 байт, state ok + узел 11 (asocks-mobile-3, 95.55.49.98) → HTTP 200, 617 407 байт, state ok + + То есть 403 — свойство ПАРЫ «узел × Циан», а не площадки вообще и не нашего + запроса: detail-страница Циана авторизации не требует и отдаётся без единой куки + (проба выше шла без них). Поэтому исключение наследует `ProxyBanError` — тот же + приём, что у `AvitoBlockedError`/`DomClickBlockedError`: generic curl-слой + (`providers/_proxy.py::curl_proxy_url`) увидит `isinstance(exc, ProxyBanError)` и + снимет узел с выдачи ИМЕННО Циану (per-source бан, #2600 п.2), не трогая остальные + источники. + + Почему это заводится отдельным исключением, а не остаётся `return None`: пока 403 + гасился внутри `fetch_detail`, наружу не выходило НИЧЕГО — пул получал + `mark_health(ok=True)` на отбитый узел и продолжал выдавать его Циану, а прогон + писал `detail_failed=50` при `errors_count=0` и статусе `done` (#2700: 15 суток + подряд, 50 из 50 отказов ежедневно). + + 404 сюда НЕ относится: удалённое объявление — не бан, узел за него наказывать + нельзя. Остальные не-200 остаются прежним мягким отказом (`None` + WARNING). + """ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 108a7891..645d7420 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -220,6 +220,61 @@ def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: ) +# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило. +# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд +# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу +# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90 +# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не +# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд. +_PHASE_MIN_ATTEMPTS = 3 + + +def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: + """Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None. + + Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит + случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на + сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ. + + Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50, + detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала + HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового + прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ + подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`). + + Признак — собственная бухгалтерия фазы: `_failed == _attempted` при + `attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ + `X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это + ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя + настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv. + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'. + + Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из + 3 292 'done' (1.6%) — 33 cian_city_sweep* (detail 26, houses 15 — часть прогонов + попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал). + Остальные 3 240 остаются 'done'. + """ + for key in sorted(counters): + if not key.endswith("_attempted"): + continue + phase = key[: -len("_attempted")] + attempted = _pick_int(counters, key) + failed = _pick_int(counters, f"{phase}_failed") + if attempted is None or failed is None: + continue + if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted: + return ( + f"phase-honest-status: фаза '{phase}' отказала полностью — " + f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы " + f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. " + f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус " + f"'failed' без диагноза (#2700)" + ) + return None + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -562,12 +617,21 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, из-за которого задача и появилась. + + #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка + целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано + ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет». """ did_nothing = _sweep_run_did_nothing(counters) if did_nothing is not None: logger.error("%s run_id=%d", did_nothing, run_id) mark_failed(db, run_id, did_nothing, counters) return + phase_dead = _phase_totally_failed(counters) + if phase_dead is not None: + logger.error("%s run_id=%d", phase_dead, run_id) + mark_failed(db, run_id, phase_dead, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py index 85dfbd4f..196845d3 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py @@ -23,6 +23,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from scraper_kit.ceiling_height import plausible_ceiling_m +from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.cian_state_parser import extract_all_states, extract_state from scraper_kit.offer_price_history import clamp_diff_percent from scraper_kit.providers._base import build_curl_cffi_session @@ -76,6 +77,23 @@ class DetailEnrichment: raw_sister_states: dict[str, Any] = field(default_factory=dict) +def _raise_if_blocked(offer_url: str, status_code: int) -> None: + """HTTP 403 → `CianBlockedError`; остальные статусы — молча возврат (#2700). + + Ровно один статус, и это не список маркеров: 403 отдаёт WAF-страница Циана + (`cian_waf_block`, 21 КБ), и живая проба 2026-08-10 показала, что через здоровые + узлы тот же URL отдаётся 200-й. 404 (объявление снято) баном не считается — + иначе мёртвый лот снимал бы с выдачи здоровый узел. + + Маркеры страницы намеренно НЕ проверяются: список маркеров объясняет уже + случившийся отказ и молчит про неизвестный (урок #2767 от 09.08 — страница + блокировки восемь суток проходила как «маркеров нет»). Статус ответа такого + свойства не имеет. + """ + if status_code == 403: + raise CianBlockedError(f"Cian detail {offer_url} → HTTP 403 (WAF-блок узла)") + + async def fetch_detail( offer_url: str, *, @@ -99,6 +117,12 @@ async def fetch_detail( Caller is responsible for the context-manager lifecycle of the fetcher. Returns: DetailEnrichment, or None если fetch / parse failed. + + Raises: + CianBlockedError: HTTP 403 на curl-путях — WAF Циана отбил узел, с которого мы + пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают + исключение в `errors_count`, а на own-session-пути оно дополнительно снимает + узел с выдачи Циану через `curl_proxy_url`. """ if browser_fetcher is not None: # Browser path: get fully JS-rendered HTML; same parse path follows. @@ -111,6 +135,7 @@ async def fetch_detail( # Shared curl_cffi-сессия (прокси уже применён caller'ом) — пул не трогаем. resp = await session.get(offer_url, allow_redirects=True) if resp.status_code != 200: + _raise_if_blocked(offer_url, resp.status_code) logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code) return None html = resp.text @@ -132,6 +157,11 @@ async def fetch_detail( try: resp = await own_session.get(offer_url, allow_redirects=True) if resp.status_code != 200: + # ВНУТРИ curl_proxy_url: поднятый отсюда ProxyBanError доходит до + # пула (mark_banned на пару «узел × cian», #2600 п.2). Раньше здесь + # был `return None` — узел получал mark_health(ok=True) и оставался + # в выдаче Циану (#2700, 15 суток по 50 отказов в сутки). + _raise_if_blocked(offer_url, resp.status_code) logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code) return None html = resp.text From 74344f7b8b9713c0a31f4a97d1190e327f6b4f83 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 11:49:27 +0000 Subject: [PATCH 121/130] =?UTF-8?q?docs(tradein/scraper):=2042=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=2052=20=E2=80=94=20=D0=B2=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D1=8B=20=C2=AB=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=20=C3=97=20=D1=84=D0=B0=D0=B7=D0=B0=C2=BB=20(#2700?= =?UTF-8?q?)=20(#2822)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/scrape_runs.py | 10 ++++++---- .../scraper-kit/src/scraper_kit/orchestration/runs.py | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index d1471ebf..0ecf5ee2 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -256,10 +256,12 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'. - Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из - 3 292 'done' (1.6%) — 33 cian_city_sweep* (detail 26, houses 15 — часть прогонов - попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал). - Остальные 3 240 остаются 'done'. + Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по + боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed' + 42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не + знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 — + это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов + отказали обе фазы (detail и houses) сразу, и они посчитались дважды. """ for key in sorted(counters): if not key.endswith("_attempted"): diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 645d7420..c0996e1b 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -251,10 +251,12 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'. - Замер на проде 2026-08-10 за 90 суток: правило переводит в 'failed' 52 прогона из - 3 292 'done' (1.6%) — 33 cian_city_sweep* (detail 26, houses 15 — часть прогонов - попадает по обеим фазам) и 11 avito_city_sweep* (detail; про эти никто не знал). - Остальные 3 240 остаются 'done'. + Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по + боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed' + 42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не + знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 — + это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов + отказали обе фазы (detail и houses) сразу, и они посчитались дважды. """ for key in sorted(counters): if not key.endswith("_attempted"): From 9d9457f67dc24c2d724fd504112e006846edf68f Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 10 Aug 2026 15:42:22 +0000 Subject: [PATCH 122/130] =?UTF-8?q?fix(tradein/estimate):=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D1=83=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D1=8F=D1=82=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D0=B8=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20(#2823)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/schemas/trade_in.py | 35 ++ tradein-mvp/backend/app/services/estimator.py | 453 +++++++++++++++--- .../app/services/exporters/trade_in_pdf.py | 68 ++- .../test_estimator_headline_sufficiency.py | 348 ++++++++++---- .../backend/tests/test_pdf_security.py | 80 ++++ .../tests/test_street_deals_endpoint.py | 58 +++ tradein-mvp/frontend/src/app/v2/page.tsx | 42 +- .../src/components/trade-in/HeroSummary.tsx | 11 +- .../src/components/trade-in/ListingsCard.tsx | 24 +- .../trade-in/v2/LowConfidenceBanner.tsx | 112 +++++ .../components/trade-in/v2/ParamsPanel.tsx | 10 +- .../src/components/trade-in/v2/SourcesMap.tsx | 18 +- .../src/components/trade-in/v2/mappers.ts | 15 +- .../src/components/trade-in/v2/ui-config.ts | 7 +- tradein-mvp/frontend/src/types/trade-in.ts | 15 + 15 files changed, 1117 insertions(+), 179 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 4f3d4996..b7cb3556 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -319,6 +319,41 @@ class AggregatedEstimate(BaseModel): cv: float | None = None source_counts: dict[str, int] = Field(default_factory=dict) created_at: datetime | None = None + # ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10, + # #oblast-E priority RESTORED same day — see estimator.py module + # docstring for the full 3-way headline-source rule) ────────────────── + # Product requirement: an estimate is ALWAYS surfaced — a thin base sample + # (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First + # estimator.estimate_quality() progressively relaxes the analog SEARCH + # (room-count adjacency → freshness window → novostroyki segment → radius) + # trying to grow the sample past the threshold; if it's STILL thin, + # _price_from_inputs() prefers a usable ДКП deals corridor over a noisy + # thin listings median when one is available (restored #oblast-E + # priority — the Серов repro: 3 listings must not outrank 54 deals), and + # only falls back to the thin listings median itself when no corridor + # exists. Real refusal happens only at genuine zero (no listings AND no + # usable anchor/deals). + # relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага + # ослабления, готовые к показу пользователю как честный дисклеймер рядом с + # confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад + # не понадобился (обычный случай). Возможные значения (дословно, фронт + # может на них завязываться): "снят фильтр по году постройки", + # "учтены студии", "комнатность ±1", "объявления за 60 дней", + # "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м", + # "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП + # deals corridor because the base listings sample was thin — a source + # SWITCH, not a search widening, but surfaced the same way). + # reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs + # (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если + # relaxations непусто (выборка набралась только ценой ослаблений); капается + # на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой + # выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего». + # НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное + # ограничение, каскад не переигрывается из сохранённых analogs). НЕ + # путать с `confidence` (Literal low/medium/high — старая метрика на + # основе уникальных адресов/IQR, см. её собственный докстринг выше). + relaxations: list[str] = Field(default_factory=list) + reliability: Literal["ok", "low", "very_low"] = "ok" # ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку # при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ── area_m2: float | None = None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index eeced17f..01f8871a 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -185,6 +185,24 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3 # ИТОГОВОЙ выборке как headline-источнику. HEADLINE_LISTINGS_MIN_N = 5 +# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live +# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4 +# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34; +# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10). +# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового +# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка +# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять +# параметры подбора (см. #oblast-F блок там), от наименее к наиболее +# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент +# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в +# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт +# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а +# только помечает результат как низконадёжный. +RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1 +LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней +RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000) +RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум + # #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals. # Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm² # to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ). @@ -2562,12 +2580,28 @@ class PricingResult: # headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV # радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных). cv: float | None = None - # #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N - # (headline suppressed, listings_clean deliberately left intact — see gate - # comment above). Caller uses this to also keep the thin listings out of the - # display `analogs` cards when no anchor overrides the headline. 0 = either - # sufficient listings were used, or genuinely zero were found. + # #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже + # HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет + # headline/listings_clean — median/n_analogs остаются реальными, поле лишь + # маркирует «низкая надёжность» (confidence='low' + честный explanation, + # см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов + # вообще не нашлось. listings_headline_thin_n: int = 0 + # #oblast-E (restored priority, product correction 2026-08-10): True когда + # headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая + # выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен + # достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже). + # Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись + # «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на + # 'low' (не выше). False во всех остальных случаях, включая genuinely-zero + # listings deals-fallback (тот же блок, но без тонкой выборки позади). + deals_headline_due_to_thin_listings: bool = False + + +def _analog_word_dative(n: int) -> str: + """Дательный падеж существительного «аналог» для confidence_explanation + тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам».""" + return "аналогу" if n == 1 else "аналогам" def _price_from_inputs( @@ -2646,45 +2680,82 @@ def _price_from_inputs( n_analogs = 0 cv = None - # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above). - # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust - # as a market median — suppress the AGGREGATE (median/range/n_analogs/cv) - # exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/ - # insufficient_data chain below all take the already-honest zero-analogs - # path automatically (no new branches there). `listings_clean` itself is - # deliberately LEFT INTACT (not cleared) — the same-building anchor's own - # ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell - # "genuinely zero nearby listings" from "some nearby listings, just too few - # to trust as THIS estimate's headline" — those are different confidence - # signals and clearing the list here would conflate them. The caller - # (estimate_quality) uses `listings_headline_thin_n` on the returned - # PricingResult to also keep suppressed listings out of the display - # `analogs` cards when no anchor overrides the headline (n_analogs - # invariant: cards shown ⊆ what n_analogs counts). + # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above) + # — priority RESTORED 2026-08-10 (product correction on top of #oblast-F): + # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy + # enough to headline on their OWN — a more reliable source should win when + # one exists. Two sub-cases: + # (i) a usable ДКП deals corridor is available (same threshold the + # #oblast-D deals-headline-fallback block below itself requires, + # DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the + # listings AGGREGATE is suppressed to zero here so that block takes + # over the headline, EXACTLY like original #oblast-E. This is the + # Серов repro this gate exists for: n=3 listings must not outrank a + # 54-deal corridor. `listings_clean` stays intact (never cleared) — + # both for the anchor ghost-anchor guard (#1871) AND so + # estimate_quality() still surfaces these listings as display + # `analogs` cards even though they no longer drive n_analogs/median. + # (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the + # real thin median rather than refusing outright. By the time + # control reaches this function, estimate_quality() has already run + # the #oblast-F relaxation cascade (room-adjacency / freshness / + # novostroyki / radius) trying to grow the sample past the + # threshold — `listings` here is whatever that cascade could find. + # NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from + # the `deals_headline_due_to_thin_listings` PricingResult field set later — + # this one fires as soon as the gate DECIDES to cede (used below to skip + # the repair-coefficient/explanation blocks safely, regardless of whether + # anchor later overrides); the field fires only once the #oblast-D + # deals-headline-fallback block ACTUALLY builds the headline from deals + # (anchor may still override in between — see that block). listings_headline_thin_n = 0 + gate_ceded_to_deals = False + # Outward PricingResult field — set True below, ONLY inside the actual + # #oblast-D deals-headline-fallback block, once it fires for THIS reason. + deals_headline_due_to_thin_listings = False if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N: listings_headline_thin_n = n_analogs - logger.info( - "headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing " - "listings-derived median (falling back to anchor/deals/insufficient_data)", - n_analogs, - HEADLINE_LISTINGS_MIN_N, + dkp_corridor_usable = ( + dkp_raw is not None + and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N + and dkp_raw.get("median_ppm2", 0) > 0 ) - median_ppm2 = 0.0 - q1_ppm2 = 0.0 - q3_ppm2 = 0.0 - median_price = 0 - range_low = 0 - range_high = 0 - n_analogs = 0 - cv = None + if dkp_corridor_usable: + gate_ceded_to_deals = True + logger.info( + "headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП " + "corridor (n=%s) available — suppressing listings-derived median, " + "ceding headline to deals/anchor chain", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + dkp_raw.get("count", 0) if dkp_raw else None, + ) + median_ppm2 = 0.0 + q1_ppm2 = 0.0 + q3_ppm2 = 0.0 + median_price = 0 + range_low = 0 + range_high = 0 + n_analogs = 0 + cv = None + else: + logger.info( + "headline sufficiency note #oblast-E: n=%d < %d listings, no usable " + "ДКП corridor — keeping real median, flagged low-reliability " + "(#oblast-F: never suppressed to zero without a fallback source)", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + ) - # 4b. Repair coefficient — skipped when the headline was thin-suppressed - # above (median_price is already 0; applying a coefficient would leave it - # 0 but still emit a misleading "adjusted for repair state" note). + # 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING + # thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is + # meaningful there. Skipped when the gate ceded the headline to deals + # (gate_ceded_to_deals — median_price is already 0 above; applying a + # coefficient would leave it 0 but still emit a misleading "adjusted for + # repair state" note, same reasoning original #oblast-E used). repair_coef = _repair_coefficient(repair_state) repair_note = "" - if listings_clean and not listings_headline_thin_n and repair_coef != 1.0: + if listings_clean and not gate_ceded_to_deals and repair_coef != 1.0: median_price = int(median_price * repair_coef) range_low = int(range_low * repair_coef) range_high = int(range_high * repair_coef) @@ -2725,19 +2796,20 @@ def _price_from_inputs( area_widened, listings=listings_clean, ) - # #oblast-E: honest override — _compute_confidence's generic "не найдено - # аналогов" is FALSE here (we DID find listings_headline_thin_n of them, - # just too few to trust). Stays the final explanation unless a later block - # (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest - # reasoning — both of those already check truthy `explanation` and either - # replace it (anchor) or append a construction-method clause that reads - # this same thin-count (deals-fallback), so no contradiction either way. - if listings_headline_thin_n: + # #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of + # the gate above (real thin median kept, no usable deals corridor to cede + # to). Case (i) (gate_ceded_to_deals) must NOT set this text — the + # deals-headline-fallback block below writes its OWN "built from + # Rosreestr deals" explanation; setting this first would leave a + # contradictory "Оценка построена по N аналогам" sentence stapled in front + # of it. Stays the final explanation unless a later block (anchor / + # #oblast-D deals-fallback) overwrites it with its OWN honest reasoning. + if listings_headline_thin_n and not gate_ceded_to_deals: confidence = "low" explanation = ( - f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., " - f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по " - "такой маленькой выборке слишком чувствительна к случайным лотам." + f"Оценка построена по {listings_headline_thin_n} " + f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, " + "точность снижена." ) # Tier note — информируем пользователя о качестве house-match @@ -3316,10 +3388,16 @@ def _price_from_inputs( n_analogs = 0 confidence = "low" cv = None - # #oblast-E: differentiate "genuinely zero listings" (unchanged wording) - # from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" — - # the latter must NOT claim "рядом нет объявлений" (false, contradicts the - # thin-sufficiency explanation already set above this block). + # #oblast-E (priority restored 2026-08-10): differentiate "genuinely + # zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N, + # ceded to the deals corridor" (gate above, case i) — the latter must + # NOT claim "рядом нет объявлений" (false — some WERE found, just not + # trusted as headline on their own). `deals_headline_due_to_thin_ + # listings` (returned on PricingResult) tells estimate_quality() this + # was the thin-cession path specifically, so it can (a) append the + # "оценка по сделкам — мало объявлений рядом" relaxation label, (b) + # cap reliability at 'low' — a real deals corridor is a real signal, + # just not a listings-comp one. no_listings_clause = ( f" Из {listings_headline_thin_n} найденных объявлений недостаточно для " "надёжной медианы —" @@ -3331,9 +3409,11 @@ def _price_from_inputs( f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.)," " точность ориентировочная." ) + if listings_headline_thin_n: + deals_headline_due_to_thin_listings = True logger.info( "deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline" - " (listings=0 [thin_suppressed=%d], anchor=None)", + " (listings=0 [thin_ceded=%d], anchor=None)", int(median_ppm2), dkp_raw["count"], listings_headline_thin_n, @@ -3469,6 +3549,7 @@ def _price_from_inputs( listings_clean=listings_clean, cv=cv, listings_headline_thin_n=listings_headline_thin_n, + deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings, ) @@ -3730,6 +3811,9 @@ async def estimate_quality( house_type=target_house_type, total_floors=payload.total_floors, ) + # #oblast-F: True only when there WAS a cohort (year_built) filter to drop — + # surfaced later as the "снят фильтр по году постройки" relaxation label. + cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0 area_widened = False if len(listings) < 5: @@ -3778,6 +3862,146 @@ async def estimate_quality( analog_tier = analog_tier_wa search_radius_m = fallback_radius_m + # ── #oblast-F: relaxation cascade (never-block estimate, product decision + # 2026-08-10) ────────────────────────────────────────────────────────── + # Product requirement: NEVER refuse an estimate outright. If the 4-tier + # cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N, + # keep loosening search criteria — least → most distorting — until either + # the sample clears the threshold or we run out of steps. Every step that + # ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced + # via AggregatedEstimate.relaxations + appended to confidence_explanation + # below) so a low-reliability estimate honestly explains why it stretched + # the search. Each step carries FORWARD the relaxations already applied by + # earlier steps (cumulative widening), not just its own single criterion. + relaxations: list[str] = [] + if cohort_dropped: + relaxations.append("снят фильтр по году постройки") + + cur_rooms_min: int | None = None + cur_rooms_max: int | None = None + cur_fresh_days = LISTINGS_FRESH_DAYS + cur_allow_novostroyki = False + cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE + + async def _try_relax( + *, + rooms_min: int | None, + rooms_max: int | None, + fresh_days: int, + allow_novostroyki: bool, + radius_m: int, + area_tolerance: float, + ) -> tuple[list[dict[str, Any]], str] | None: + """Один шаг каскада #oblast-F. Возвращает (listings, tier) только если + кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана + (ничего реально не выиграла) и вызывающий её не применяет.""" + candidate, _, tier = await asyncio.to_thread( + _fetch_analogs, + db, + lat=geo.lat, + lon=geo.lon, + rooms=payload.rooms, + rooms_min=rooms_min, + rooms_max=rooms_max, + area=payload.area_m2, + radius_m=radius_m, + area_tolerance=area_tolerance, + fresh_days=fresh_days, + allow_novostroyki=allow_novostroyki, + full_address=geo.full_address, + target_house_id=target_house_id, + year_built=target_year, + house_type=target_house_type, + total_floors=payload.total_floors, + ) + if len(candidate) > len(listings): + return candidate, tier + return None + + # (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для + # rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0 + # тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA. + if len(listings) < HEADLINE_LISTINGS_MIN_N: + if payload.rooms <= 1: + try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии" + else: + try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA + try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA + rooms_label = "комнатность ±1" + rooms_result = await _try_relax( + rooms_min=try_rooms_min, + rooms_max=try_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=cur_allow_novostroyki, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if rooms_result is not None: + listings, analog_tier = rooms_result + cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max + relaxations.append(rooms_label) + + # (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60). + if len(listings) < HEADLINE_LISTINGS_MIN_N: + fresh_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=LISTINGS_FRESH_DAYS_RELAXED, + allow_novostroyki=cur_allow_novostroyki, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if fresh_result is not None: + listings, analog_tier = fresh_result + cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED + relaxations.append("объявления за 60 дней") + + # (c) снять guard listing_segment — допустить новостройки в comp-пул. + if len(listings) < HEADLINE_LISTINGS_MIN_N: + novo_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=True, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if novo_result is not None: + listings, analog_tier = novo_result + cur_allow_novostroyki = True + relaxations.append("учтены новостройки") + + # (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M. + # Пропускается, когда пользователь явно зафиксировал radius_m — тот же + # контракт, что и у существующего radius-fallback выше (#2044: сервер не + # авто-расширяет поиск за пределы выбранного пользователем радиуса). + if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None: + for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M): + if relax_radius <= search_radius_m: + continue + radius_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=cur_allow_novostroyki, + radius_m=relax_radius, + area_tolerance=cur_area_tolerance, + ) + if radius_result is not None: + listings, analog_tier = radius_result + search_radius_m = relax_radius + fallback_used = True + if len(listings) >= HEADLINE_LISTINGS_MIN_N: + break + + # Area/radius relaxations derived from FINAL state (covers both the + # pre-existing Tier B/C radius/area widening above AND step (d) here) — + # a single check avoids double-labelling the same underlying widening. + if area_widened: + relaxations.append("площадь ±25%") + if search_radius_m > base_radius_m: + relaxations.append(f"радиус расширен до {search_radius_m} м") + # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы # corridor_high был доступен для Tier C-гейта и soft-клампа headline. @@ -3990,7 +4214,41 @@ async def estimate_quality( ratio_basis = pr.ratio_basis listings_clean = pr.listings_clean cv = pr.cv - listings_headline_thin_n = pr.listings_headline_thin_n + + # #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals + # corridor because the base listings sample was thin — a real signal (real + # Rosreestr deals), just not a listings-comp one. Recorded as its own + # relaxation label (distinct from the #oblast-F cascade labels above, which + # describe attempts to grow the LISTINGS sample — this describes switching + # sources entirely). + if pr.deals_headline_due_to_thin_listings: + relaxations.append("оценка по сделкам — мало объявлений рядом") + + # #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/ + # deals-fallback override above) — independent of `confidence` (older + # unique-address/IQR metric, see AggregatedEstimate docstring). If the + # #oblast-F cascade had to relax anything to get here, an otherwise-"ok" + # sample is downgraded to "low" — the raw count looks fine, but it only + # exists because we widened the search past the user's exact criteria. + if n_analogs >= 8: + reliability: Literal["ok", "low", "very_low"] = "ok" + elif n_analogs >= 3: + reliability = "low" + else: + reliability = "very_low" + if pr.deals_headline_due_to_thin_listings: + # #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero — + # the headline came from deals instead), which would otherwise bucket + # to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is + # a real, meaningful signal — "не выше low" (product spec), not + # "почти нет сигнала" (what 'very_low' would imply here). + reliability = "low" + elif relaxations and reliability == "ok": + reliability = "low" + if relaxations: + explanation = (explanation or "") + ( + " Применены послабления подбора: " + ", ".join(relaxations) + "." + ) # 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals. # Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков @@ -4026,14 +4284,6 @@ async def estimate_quality( # иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому # набору (или = None при пустом listings_clean, хотя у комплов данные есть). metadata_lots = display_pool - elif listings_headline_thin_n: - # #oblast-E: headline was suppressed (thin radius sample, no anchor to - # take over) — do NOT surface those same listings as display cards - # either, else `analogs` would show N cards while n_analogs==0 (broken - # invariant, same dishonesty this gate exists to remove). Degrades to - # the exact same empty-display state as "genuinely zero listings". - analogs_lots = [] - metadata_lots = [] else: # display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что # дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs. @@ -4355,6 +4605,11 @@ async def estimate_quality( cv=cv, source_counts=source_counts, created_at=now, + # #oblast-F (never-block relaxation cascade) — применённые ослабления + # подбора + производная надёжность выборки (см. reliability computation + # above, независимо от `confidence`). + relaxations=relaxations, + reliability=reliability, ) @@ -4760,11 +5015,27 @@ def _extract_short_addr(full_address: str | None) -> str | None: # Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе. # Работает для FORWARD и REVERSE форматов Nominatim. +# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после +# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ +# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт +# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый +# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе +# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract +# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе. +# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` — +# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest- +# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+` +# после этого не находит пробел (следующий символ — "и"), движок +# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит +# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают +# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная +# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже +# покрывает оба варианта (с точкой и без). _STREET_KW_RE = re.compile( r"(? NOW() - (:fresh_days || ' days')::interval @@ -4956,8 +5230,13 @@ _COMMON_WHERE = """ -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает -- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента — - -- это вторичка или неклассифицированный объект). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki + -- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) — + -- дефолт False сохраняет канон-guard byte-identical. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled, -- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒ -- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает @@ -5012,6 +5291,14 @@ def _fetch_analogs( cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive target_house_id: int | None = None, # #6: canonical house for same-building Tier S + # #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты + # byte-identical старому поведению (exact rooms match / 14 дней / без + # новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО + # когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring. + rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms + rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms + fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED + allow_novostroyki: bool = False, # #oblast-F (c) ) -> tuple[list[dict[str, Any]], bool, str]: """SELECT аналогов — трёхуровневый house-match (S → H → W). @@ -5052,21 +5339,29 @@ def _fetch_analogs( """ area_min = area * (1 - area_tolerance) area_max = area * (1 + area_tolerance) + # #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms), + # byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a + # widened range only past HEADLINE_LISTINGS_MIN_N thinness. + eff_rooms_min = rooms if rooms_min is None else rooms_min + eff_rooms_max = rooms if rooms_max is None else rooms_max # #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL # (безвредно без фильтра); статический фрагмент управляет только применением # `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3 # bind-параметры не задействованы, инъекции нет. dup_filter = "AND rn_dup = 1" base_params: dict[str, Any] = { - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, "cohort_year_max": cohort_year_max, # #2012: is_apartments hard-filter — see _COMMON_WHERE comment above. "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, + # #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above. + "allow_novostroyki": allow_novostroyki, } # ── Tier S (canonical): same building via house_id_fk ───────────────────── @@ -5391,7 +5686,8 @@ def _fetch_analogs( FROM listings WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) AND (geo_precision IS DISTINCT FROM 'city') - AND rooms = :rooms + -- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же. + AND rooms BETWEEN :rooms_min AND :rooms_max AND area_m2 BETWEEN :area_min AND :area_max AND is_active = true AND scraped_at > NOW() - (:fresh_days || ' days')::interval @@ -5408,7 +5704,11 @@ def _fetch_analogs( ) -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий -- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению). AND ( @@ -5450,16 +5750,18 @@ def _fetch_analogs( "lat": lat, "lon": lon, "radius": radius_m, - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "target_year": year_built, "target_house_type": house_type, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, # NEW "cohort_year_max": cohort_year_max, # NEW "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012 + "allow_novostroyki": allow_novostroyki, # #oblast-F (c) }, ) .mappings() @@ -6570,4 +6872,7 @@ def _empty_estimate( # Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна. address_precision=None, analog_tier=None, # нет данных при empty estimate + # #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся + # (geocode failed / no coords), а не просто "мало нашлось". + reliability="very_low", ) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 8282f83c..676d8f71 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -1243,11 +1243,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int return min(prices), max(prices) +def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str: + """#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest + footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals + corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT + estimate.analogs is non-empty (the thin listings that triggered the cession + are still shown below as reference cards — never cleared, see estimator.py + #1871 ghost-anchor guard). Same tone/plain-sentence style as the web + LowConfidenceBanner for this scenario. Empty string (no-op) otherwise — + covers both "healthy sample" and "genuinely zero, nothing to show" cases.""" + if estimate.n_analogs != 0 or not estimate.analogs: + return "" + return ( + f'

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

" + ) + + +def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str: + """#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability` + (estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report + already shows this (LowConfidenceBanner); the PDF stayed silent, a + client-visible discrepancy between the two. Empty string (no-op) when + reliability=='ok' and relaxations is empty — the common, unrelaxed case, + byte-identical to the report before these fields existed.""" + if estimate.reliability == "ok" and not estimate.relaxations: + return "" + if estimate.relaxations: + detail = "Подбор аналогов расширен: " + ", ".join( + _html.escape(r) for r in estimate.relaxations + ) + else: + # relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка, + # которую каскад ослаблений не смог расширить, см. estimator.py + # #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика, + # что и в счётчике выше страницы). + detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)" + return f""" +
+ Точность оценки снижена. + {detail} — данные ниже приведены с этой оговоркой. +
+""" + + # ── Page 2: Listings (market) ──────────────────────────────────────────────── def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] - n_total = estimate.n_analogs + # #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw + # estimate.n_analogs is the count of listings that drove the HEADLINE math — + # it is deliberately 0 when the headline was ceded to the ДКП deals corridor + # (estimator.py `deals_headline_due_to_thin_listings`), even though the thin + # listings that triggered that cession are still shown below as display cards + # (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor + # guard comment). Printing raw n_analogs there read as "0 шт." above a + # non-empty examples table — a client-visible contradiction. n_analogs is + # normally >= len(analogs) (analogs is a top-10-capped SUBSET of what + # n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a + # no-op in that common case (count stays the honest FULL n_analogs) and only + # changes anything in this one pathological case, where it falls back to + # "how many are actually shown" instead of the dishonest zero. + n_total = max(estimate.n_analogs, len(estimate.analogs)) # #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует # аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160), # а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного @@ -1306,6 +1366,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra examples_rows = _examples_rows(top5) heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ") + # #pdf-honesty — see helper docstrings above. Both no-op ("") in the common + # (unrelaxed, non-deals-sourced) case — byte-identical page in that case. + deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate) + reliability_note = _reliability_note_html(estimate, n_total) return f"""
@@ -1320,6 +1384,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
#ТипИсточник Статус Старт Финиш diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 233bfc46..fd5fa55f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -216,7 +216,9 @@ def _alert_on_run_id( def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: """INSERT scrape_runs(source, status='running', params, started_at=NOW()). - run_type DEFAULT 'city_sweep' (из 051 миграции). + Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …); + отдельной колонки run_type больше нет — она 3244 прогона подряд молчала + дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674). Returns run_id (bigint). """ row = db.execute( @@ -549,7 +551,7 @@ def list_all( db.execute( text( f""" - SELECT id AS run_id, source, run_type, status, params, counters, + SELECT id AS run_id, source, status, params, counters, total_seen, new_count, started_at, finished_at, heartbeat_at, error AS error_text FROM scrape_runs From e4ac0365cf2285d30efe3eef12938d9cf40180cd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 03:39:37 +0500 Subject: [PATCH 022/130] =?UTF-8?q?fix(tradein/avito):=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=80=D0=B5=D1=82=D1=80=D0=BE=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D0=B2=D1=8B=20=D0=BF=D0=BE=D0=B4=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=20+=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B0=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Окно и такт разъехались. Каденс avito_full_load задаёт default_params.interval_days, глубину обхода — incremental_days (since = today - N, ранняя остановка по listing_date). Это два независимых литерала, обязанных совпадать: 129 поставил окно 2 при ежедневном такте (перекрытие было), 206 перевёл такт на 7 суток и окно не тронул. Прогон видит [D-2, D] = 3 суток из 7; дни D+1..D+4 не попадают ни в один прогон. Числа с прода (tradein, read-only 2026-08-06). 2026-06-21 — единственный день, когда оба обхода отработали: инкрементальный run 297 — 2804 unique, exhaustive run 295 — 9992 unique, то есть окно в 2 суток достаёт 28.1% инвентаря. Когорта run 295, не виденная после 23.06 (listing_date заморожен): полоса [D-2, D] — 189 лотов, полоса [D-7, D-3] — ещё 427, расширение окна берёт в 3.26 раза больше. Гистограмма (last_seen_at::date - listing_date) за 20 суток: возраст 0-2 — 594, возраст ровно 7 — 1212 (51% датированных наблюдений) — у Avito недельный авто-подъём, sortTimeStamp сдвигается кратно 7. Структурный минимум бездырочного покрытия — 6, но 6 режет ровно по этому пику; 7 = такт, полосы соседних прогонов смыкаются с суточным перехлёстом под дрейф расписания (замер: last_run 03.08 13:37 -> next_run_at 10.08 14:16). Цена: страниц примерно втрое больше на прогон, но прогон недельный. До 206 система платила ~150-370 страниц семь раз в неделю; после правки — ~500-1200 в неделю, всё ещё примерно вдвое дешевле, чем до 206. Чиню в двух местах: миграция 215 выводит окно из фактического interval_days строки (GREATEST — не сужает окно шире такта), scheduler расширяет его на лету и пишет warning, чтобы расхождение не вернулось следующей правкой каденса. Полный обход: причина не в площадке. Все пять banned-прогонов avito_full_load_exhaustive (05.07-02.08) несут один текст — "browser-sidecar error: browser unavailable (proxy may be down)", то есть 503 от своего же сайдкара, у которого не поднялся камуфокс. В те же дни avito_city_sweep (20 done), avito_newbuilding_sweep (21) и avito_detail_backfill (63) работали. Корень — мёртвый BROWSER_PROXY_AVITO (ard.mobileproxy.space) в env сайдкара, куда полный обход проваливался, потому что строил свой BrowserFetcher без пула; починено не здесь, а #2637 (02.08, пул для браузерного пути Авито) и #2616 шаг 2 (05.08, снос мёртвых env). Прод подтверждает: 0 лотов 05/12/19/26.07, 2334 и 362 в двух прогонах после 02.08. Остаток, который чинится кодом, здесь: 503 сайдкара классифицировался как soft-ban и уходил в бюджет IP-ротации, а ротация снята (#2616 шаг 2, max_rot=0) — условие rot_done < max_rot ложно всегда, а бюджет коротких backoff-retry стоял в else и был для soft-ban недостижим. Ни одного ретрая на самую частую ошибку: один блип сайдкара стоил бакета, четыре подряд — всего прогона. Бюджет backoff теперь общий для обеих причин; сайдкар на таком 503 сам поднимает фоновый retry launch'а, поэтому повтор через пару секунд обычно проходит. Статус banned на такой ошибке остаётся ложью (площадка не банила) — это же чтение легло в основание 206. Здесь не трогаю: набор status ограничен CHECK-констрейнтом, а mark_banned в отличие от mark_failed сохраняет чекпоинт done_buckets. --- ...avito_full_load_window_matches_cadence.sql | 74 ++++++ .../test_2674_avito_full_load_coverage.py | 220 ++++++++++++++++++ .../scraper_kit/orchestration/scheduler.py | 19 ++ .../src/scraper_kit/providers/avito/serp.py | 68 +++--- 4 files changed, 349 insertions(+), 32 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql create mode 100644 tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py diff --git a/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql b/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql new file mode 100644 index 00000000..35bd4fe5 --- /dev/null +++ b/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql @@ -0,0 +1,74 @@ +-- 215_avito_full_load_window_matches_cadence.sql +-- Окно ретроспективы avito_full_load приводится в согласие с тактом (#2674). +-- +-- КОНТЕКСТ. Каденс источника задаётся ключом default_params."interval_days" +-- (scheduler.compute_next_run_at), глубина обхода — ключом "incremental_days" +-- (run_avito_full_load: since = today - incremental_days, дальше пагинация +-- newest-first с ранней остановкой на первой странице без карточек >= since). +-- Это ДВА независимых литерала, которые обязаны совпадать — и они разъехались: +-- 129_avito_full_load_incremental_split.sql поставил incremental_days=2 при +-- тогдашнем ежедневном такте (2 >= 1 — окно перекрывало такт, дыр не было); +-- 206_scrape_schedules_cut_wasteful_load.sql перевёл источник на interval_days=7, +-- но incremental_days не тронул. +-- Итог на проде (scrape_schedules id=138, проверено read-only 2026-08-06): +-- {"interval_days": 7, "incremental_days": 2, ...} +-- Прогон видит listing_date в [D-2, D] = 3 календарных дня из 7; следующий прогон +-- начинает с [D+5, D+7]. Дни D+1..D+4 не попадают НИ в один прогон — 4 суток из 7 +-- (57%) структурно вне поля зрения источника. +-- +-- ЧИСЛА, обосновывающие новое значение (прод, tradein): +-- * 2026-06-21 — единственный день, когда оба Avito-обхода отработали успешно: +-- инкрементальный (окно 2) run 297 — 2804 unique, exhaustive (без отсечки) +-- run 295 — 9992 unique. Окно в 2 суток достаёт 28.1% того, что достаёт +-- полный обход; 7188 лотов (71.9%) лежат ниже отсечки. +-- * Когорта run 295, не виденная после 2026-06-23 (listing_date заморожен): +-- в полосе [D-2, D] — 189 лотов, в полосе [D-7, D-3] — ещё 427. Расширение +-- окна 2 -> 7 берёт в 3.26 раза больше лотов. +-- * Гистограмма (last_seen_at::date - listing_date) по avito за последние 20 +-- суток: возраст 0-2 — 594 лота, возраст ровно 7 — 1212 лотов (51% всех +-- датированных наблюдений). У Avito недельный авто-подъём: sortTimeStamp +-- сдвигается кратно 7 суткам, поэтому на возрасте ровно 7 стоит пик. +-- Структурный минимум бездырочного покрытия — 6 (при 6 полосы соседних +-- прогонов смыкаются), но 6 режет ровно по этому пику. 7 = такт: полосы +-- [D-7, D] и [D, D+7] смыкаются с однодневным перехлёстом, который +-- покрывает дрейф расписания (замер: last_run 2026-08-03 13:37 -> +-- next_run_at 2026-08-10 14:16 = +7 суток 39 минут за цикл). +-- +-- ЦЕНА ПО ЗАПРОСАМ. Пагинация останавливается по глубине окна, поэтому число +-- страниц растёт примерно как число лотов в окне: ~3.3x к прогону. Прогоны с +-- окном 2 при delay=1.0: 2804 lots / 40 мин (run 297) .. 3989 lots / 99 мин +-- (run 620) ≈ 150-370 страниц. Окно 7 -> ≈ 500-1200 страниц НА ПРОГОН, но +-- прогон теперь недельный, а не ежедневный: до 206 система платила те же +-- 150-370 страниц СЕМЬ раз в неделю (~1050-2600). После этой правки — +-- ~500-1200 в неделю, то есть по-прежнему примерно вдвое дешевле, чем до 206. +-- +-- Значение НЕ хардкодим числом 7, а выводим из фактического interval_days строки: +-- если такт когда-нибудь поменяют снова, повторный прогон файла (или ручной +-- вызов) снова сведёт окно с тактом. Ту же инвариантность страхует код — +-- scheduler._job_avito_full_load расширяет окно до такта и пишет warning. +-- GREATEST с текущим значением: миграция никогда не СУЖАЕТ окно, если оператор +-- выставил его шире такта осознанно. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), +-- 129_avito_full_load_incremental_split.sql (incremental_days), +-- 206_scrape_schedules_cut_wasteful_load.sql (interval_days=7). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет — idempotent by construction. +-- source сравнивается на ТОЧНОЕ равенство: 'avito_full_load_exhaustive' — соседняя +-- джоба, у неё incremental_days нет вовсе (полный обход) и трогать её нельзя. + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params + || jsonb_build_object( + 'incremental_days', + GREATEST( + COALESCE(CAST(default_params ->> 'interval_days' AS integer), 1), + COALESCE(CAST(default_params ->> 'incremental_days' AS integer), 1) + ) + ), + updated_at = NOW() +WHERE source = 'avito_full_load' + AND default_params ? 'incremental_days'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py b/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py new file mode 100644 index 00000000..3fc5cd88 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py @@ -0,0 +1,220 @@ +"""#2674, Avito full load: окно ретроспективы под недельный такт + 503 сайдкара. + +Две находки эпика, обе — продолжающаяся потеря данных, а не история. + +1. Окно разошлось с тактом. `interval_days` (каденс, миграция 206) и + `incremental_days` (глубина обхода, миграция 129) — два независимых литерала, + которые обязаны совпадать. На проде было {interval_days: 7, incremental_days: 2}: + прогон видит listing_date в [D-2, D] = 3 суток из 7, дни D+1..D+4 не попадают + ни в один прогон. Чиним в двух местах — данными (миграция 215) и кодом + (scheduler расширяет окно до такта, чтобы расхождение не вернулось). + +2. 503 сайдкара не ретраился ни разу. `_fetch_serp_html_browser` классифицировал + "browser unavailable (proxy may be down)" как soft-ban и отправлял в бюджет + IP-ротации, а ротация снята в #2616 (max_rot=0, `_rotate_ip()` всегда False) → + условие `rot_done < max_rot` ложно всегда. Бюджет коротких backoff-retry стоял + в `else:` и для soft-ban был структурно недостижим. Итог на проде: один блип + сайдкара съедал бакет, четыре подряд — весь прогон + (`_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED = 4`). +""" + +from __future__ import annotations + +import re +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from scraper_kit.avito_exceptions import AvitoRateLimitedError +from scraper_kit.orchestration import scheduler as kit_sched +from scraper_kit.orchestration.scheduler import ( + SchedulerContext, + _job_avito_full_load, + _job_avito_full_load_exhaustive, +) +from scraper_kit.providers.avito import serp as serp_module +from scraper_kit.providers.avito.serp import _AVITO_SIDECAR_TRANSIENT_RETRIES, AvitoScraper + +from app.services.scraper_adapters import RealScraperConfig + +_MIGRATION_215 = ( + Path(__file__).resolve().parents[1] + / "data" + / "sql" + / "215_avito_full_load_window_matches_cadence.sql" +) + + +def _ctx() -> SchedulerContext: + return SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(), + runs=MagicMock(), + ) + + +async def _incremental_days_passed(params: dict) -> object: + """Прогнать _job_avito_full_load с params и вернуть переданный incremental_days.""" + with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run: + await _job_avito_full_load(MagicMock(), 1, params, _ctx()) + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + return kwargs.get("incremental_days") + + +# ── 1. Окно ретроспективы не уже такта ──────────────────────────────────────── + + +async def test_window_widened_to_cadence() -> None: + """Прод-конфиг на момент находки: такт 7 суток, окно 2 → окно расширяется до 7. + + Фальсификация: без правки в scheduler._job_avito_full_load сюда приезжает 2 — + ровно те «двое суток из семи», о которых говорит эпик. + """ + assert await _incremental_days_passed({"interval_days": 7, "incremental_days": 2}) == 7 + + +async def test_window_wider_than_cadence_left_alone() -> None: + """Окно ШИРЕ такта — осознанный запас оператора, не сужаем.""" + assert await _incremental_days_passed({"interval_days": 3, "incremental_days": 10}) == 10 + + +async def test_daily_cadence_keeps_window() -> None: + """Back-compat: без interval_days такт = 1 сутки, окно 2 уже перекрывает его.""" + assert await _incremental_days_passed({"incremental_days": 2}) == 2 + + +async def test_no_window_stays_exhaustive() -> None: + """Строка без incremental_days = полный обход; такт не должен её «инкрементализировать».""" + assert await _incremental_days_passed({"interval_days": 7}) is None + + +async def test_exhaustive_job_ignores_window_params() -> None: + """Соседняя джоба всегда идёт полным обходом, что бы ни лежало в её params.""" + with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run: + await _job_avito_full_load_exhaustive( + MagicMock(), 1, {"interval_days": 7, "incremental_days": 2}, _ctx() + ) + _args, kwargs = mock_run.call_args + assert kwargs.get("incremental_days") is None + + +# ── 2. 503 сайдкара получает backoff-retry, а не мгновенный отказ ───────────── + + +def _sidecar_503() -> httpx.HTTPStatusError: + request = httpx.Request("POST", "http://tradein-browser:3000/fetch") + response = httpx.Response( + 503, + json={"error": "browser unavailable (proxy may be down)"}, + request=request, + ) + return httpx.HTTPStatusError("503", request=request, response=response) + + +@pytest.mark.asyncio +async def test_sidecar_503_retried_before_giving_up() -> None: + """503 «browser unavailable» — это упавший launch камуфокса, а не бан площадки. + + Сайдкар на таком отказе сам поднимает фоновый retry launch'а, поэтому повтор + через пару секунд обычно проходит. Ждём 1 попытку + весь бюджет backoff-retry. + + Фальсификация: без правки soft-ban уходит в ветку ротации (max_rot=0 → условие + ложно всегда), бюджет backoff недостижим → ровно 1 вызов fetch и немедленный + AvitoRateLimitedError. + """ + scraper = AvitoScraper(RealScraperConfig()) + assert scraper._cffi is None + scraper._browser = AsyncMock() + scraper._browser.fetch = AsyncMock(side_effect=_sidecar_503()) + + with patch.object(serp_module.asyncio, "sleep", AsyncMock()): + with pytest.raises(AvitoRateLimitedError): + await scraper._fetch_serp_html( + "https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1 + ) + + assert scraper._browser.fetch.await_count == 1 + _AVITO_SIDECAR_TRANSIENT_RETRIES + + +@pytest.mark.asyncio +async def test_sidecar_503_recovers_without_aborting_bucket() -> None: + """Один блип сайдкара больше не стоит бакета: вторая попытка отдаёт HTML.""" + scraper = AvitoScraper(RealScraperConfig()) + scraper._browser = AsyncMock() + scraper._browser.fetch = AsyncMock( + side_effect=[_sidecar_503(), "serp"] + ) + + with patch.object(serp_module.asyncio, "sleep", AsyncMock()): + html = await scraper._fetch_serp_html( + "https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1 + ) + + assert html == "serp" + assert scraper._browser.fetch.await_count == 2 + + +# ── 3. Миграция 215: статические инварианты ────────────────────────────────── +# Живой БД в тестах нет (см. conftest.py) — эффект UPDATE'а проверяется на проде, +# здесь фиксируем форму файла: транзакционность, отсутствие DDL, точное совпадение +# source (соседняя джоба с похожим именем ловится подстрокой), вывод значения из +# фактического interval_days вместо литерала. + + +def _sql() -> str: + return _MIGRATION_215.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat() -> str: + return re.sub(r"\s+", " ", _executable_sql()).strip().lower() + + +def test_migration_215_is_transactional_and_data_only() -> None: + flat = _flat() + assert flat.startswith("begin;") + assert flat.endswith("commit;") + for ddl in ("create ", "alter ", "drop ", "truncate "): + assert ddl not in flat, f"миграция только про данные, найден DDL: {ddl!r}" + + +def test_migration_215_targets_only_avito_full_load() -> None: + """Точное равенство source, НЕ подстрока — 'avito_full_load_exhaustive' рядом.""" + flat = _flat() + assert "where source = 'avito_full_load'" in flat + assert "avito_full_load_exhaustive" not in _executable_sql() + assert " like " not in flat + + +def test_migration_215_derives_window_from_cadence() -> None: + """Окно выводится из interval_days строки, а не хардкодится числом.""" + flat = _flat() + assert "interval_days" in flat + assert "greatest(" in flat + assert re.search(r"'incremental_days',\s*greatest", flat) is not None + + +def test_migration_215_merges_params_not_overwrites() -> None: + """`||` мерджит ключ в default_params — соседние ключи обязаны выжить.""" + flat = _flat() + assert "default_params ||" in flat or "default_params\n||" in _executable_sql().lower() + assert "set default_params = jsonb_build_object" not in flat + + +def test_migration_215_has_no_psycopg_cast_trap() -> None: + """Repo-конвенция: CAST(x AS type), не x::type.""" + assert "::" not in _executable_sql() + assert "cast(" in _flat() diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index ccef889a..8c5e9519 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -519,6 +519,25 @@ async def _job_avito_full_load( ) -> None: _incremental_days = params.get("incremental_days") incremental_days = int(_incremental_days) if _incremental_days is not None else None + # Окно ретроспективы не может быть уже такта (#2674). incremental_days задаёт + # since=today-N, а прогон повторяется раз в interval_days суток → при N < interval_days + # объявления, поднятые в дни (D, D+interval_days-N), не попадают НИ в один прогон. + # Ровно это и случилось: миграция 206 перевела источник на interval_days=7, оставив + # incremental_days=2 из миграции 129 → 3 календарных дня из 7 в поле зрения. + # Два независимых литерала, которые обязаны совпадать, однажды уже разъехались — + # поэтому расхождение чиним здесь, а не только данными. + interval_days = max(1, int(params.get("interval_days", 1))) + if incremental_days is not None and incremental_days < interval_days: + logger.warning( + "avito_full_load: окно ретроспективы incremental_days=%d уже такта " + "interval_days=%d — расширяю до %d, иначе %d суток объявлений не видит " + "ни один прогон", + incremental_days, + interval_days, + interval_days, + interval_days - incremental_days, + ) + incremental_days = interval_days await run_avito_full_load( db, run_id=run_id, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index c52c2d8d..2b05b055 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -505,9 +505,9 @@ class AvitoScraper(BaseScraper): # условным на avito_proxy_rotate_url, который больше не существует). max_rot = 0 rot_done = 0 - # Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/ - # crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient) - # уменьшает свой счётчик → цикл всегда движется к условию исчерпания. + # Бюджет коротких backoff-retry на ЛЮБУЮ sidecar-ошибку (timeout/crash И + # soft-ban/503) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / + # backoff) уменьшает свой счётчик → цикл всегда движется к исчерпанию. transient_left = _AVITO_SIDECAR_TRANSIENT_RETRIES while True: try: @@ -541,36 +541,40 @@ class AvitoScraper(BaseScraper): or "browser unavailable" in lowered or "proxy may be down" in lowered ) - if is_soft_ban: + if is_soft_ban and rot_done < max_rot and await self._rotate_ip(): # Soft proxy rate-limit / прокси недоступен → свежий exit-IP. - if rot_done < max_rot and await self._rotate_ip(): - rot_done += 1 - logger.info( - "avito page=%d sidecar soft-ban (status=%d) — " - "retry after rotation #%d: %s", - page, - status, - rot_done, - error_text[:200], - ) - continue - else: - # Transient (Page.goto Timeout / Target closed / прочие 5xx) → - # короткий backoff-retry того же url БЕЗ ротации. - if transient_left > 0: - transient_left -= 1 - backoff = _AVITO_SIDECAR_TRANSIENT_BACKOFF_SEC + random.uniform(0, 0.5) - logger.warning( - "avito page=%d sidecar transient (status=%d) — " - "retry in %.1fs (left=%d): %s", - page, - status, - backoff, - transient_left, - error_text[:200], - ) - await asyncio.sleep(backoff) - continue + rot_done += 1 + logger.info( + "avito page=%d sidecar soft-ban (status=%d) — retry after rotation #%d: %s", + page, + status, + rot_done, + error_text[:200], + ) + continue + # Backoff-retry того же url БЕЗ ротации. Раньше эта ветка стояла в + # `else:` к soft-ban и была для него НЕДОСТИЖИМА, а бюджет ротации с + # #2616 шага 2 равен нулю (max_rot=0, _rotate_ip() всегда False) — + # то есть 503 «browser unavailable (proxy may be down)» от сайдкара + # не ретраился ВООБЩЕ, ни разу (#2674). Сайдкар на таком 503 сам + # поднимает фоновый retry launch'а браузера, так что повтор через + # пару секунд обычно проходит; вместо этого один блип сайдкара + # съедал бакет, четыре подряд (_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED) + # — весь прогон. Теперь бюджет transient общий для обеих причин. + if transient_left > 0: + transient_left -= 1 + backoff = _AVITO_SIDECAR_TRANSIENT_BACKOFF_SEC + random.uniform(0, 0.5) + logger.warning( + "avito page=%d sidecar %s (status=%d) — retry in %.1fs (left=%d): %s", + page, + "soft-ban" if is_soft_ban else "transient", + status, + backoff, + transient_left, + error_text[:200], + ) + await asyncio.sleep(backoff) + continue # Оба бюджета (ротация + transient) исчерпаны → graceful soft-ban, # чтобы run_avito_full_load/city_sweep ушли в mark_banned (partial # preserved), а НЕ в generic except → mark_failed. Намеренно From 3c5f535e6c01f644cf96069a8217a7615d6329dd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 03:57:46 +0500 Subject: [PATCH 023/130] =?UTF-8?q?fix(tradein/admin):=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D1=83?= =?UTF-8?q?,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20view,?= =?UTF-8?q?=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=2050=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #2684 — четыре MINOR. 1. Починка фильтра открыла кнопку отмены на все 53 источника. Раньше таблица была пуста на каждой вкладке, поэтому кнопка не рендерилась НИ РАЗУ и дыра не проявлялась: ручки отмены source не проверяют вовсе. Оператор на вкладке Авито мог бы «отменить» refresh_search_matview — задача продолжила бы работать под статусом 'cancelled' (ещё один врущий статус ровно в тот день, когда их вычищаем), а has_running_run перестал бы держать single-run guard, который существует из-за инцидента с двойным свипом и баном (2026-05-31). Гейт поставлен на общем узле всех пяти ручек — scrape_runs.honors_cancel + отказ в mark_cancelled, — а не в UI: иначе ручной POST по-прежнему снимал бы guard. Флаг cancellable отдаётся в строке, UI по нему прячет кнопку. Состав набора выведен из call-site'ов runs.is_cancelled: city-sweep'ы (все площадки и города), full-load'ы, avito_newbuilding_sweep, rosreestr_dkp_import. Правило НЕ «любой *_sweep»: yandex_newbuilding_sweep отмену не опрашивает. 2. Комментарий пересозданного v_data_quality утверждал, что его обновляет /api/v1/admin/data-quality. Читателей у view нет ни одного — живая ручка строит свой запрос. PR с тезисом «ложный показатель хуже отсутствующего» не имеет права переносить в прод ложное утверждение о читателе. 3. Лимит выдачи 20 → 50: первые 20 строк по started_at на три четверти — сердцебиение proxy_healthcheck (1631 из 3245), часовой сбор мог не поместиться. Привязка к вкладке НЕ возвращается. 4. Тест «действующее определение view» искал маркер подстрокой с OR REPLACE — миграция с обычным CREATE VIEW или парой DROP+CREATE была бы невидима, и тест проверял бы 214, пока показатель уже вернулся. Заменено регуляркой на обе формы. Фальсификация трёх новых тестов патч-методом — все три красные. Полный прогон 3490 passed / 9 skipped, tsc --noEmit чистый. --- tradein-mvp/backend/app/api/v1/admin.py | 7 ++ .../backend/app/services/scrape_runs.py | 41 ++++++++- .../data/sql/214_drop_dead_run_metrics.sql | 9 +- .../tests/test_2674_dead_admin_metrics.py | 84 ++++++++++++++++++- tradein-mvp/backend/tests/test_city_sweep.py | 4 +- .../src/components/scrapers/RunsTable.tsx | 13 ++- 6 files changed, 149 insertions(+), 9 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 288e71b8..3777218c 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2201,6 +2201,12 @@ class UnifiedScrapeRunRow(BaseModel): run_id: int source: str status: str + # #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была + # пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор + # видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену + # не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а + # has_running_run перестал бы держать single-run guard. + cancellable: bool = False params: dict | None = None counters: dict | None = None total_seen: int | None = None @@ -2307,6 +2313,7 @@ def list_scrape_runs_unified( run_id=r["run_id"], source=r["source"], status=r["status"], + cancellable=runs_mod.honors_cancel(str(r["source"])), params=r.get("params"), counters=r.get("counters"), total_seen=r.get("total_seen"), diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 9090601d..b89812e1 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -250,6 +250,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None db.commit() +# Источники, чей джоб РЕАЛЬНО опрашивает status='cancelled' в своём цикле. +# Всё остальное отменить нельзя: строка стала бы 'cancelled', а задача продолжила бы +# работать — это, во-первых, ещё один врущий статус, во-вторых (хуже) обход guard'а +# has_running_run: он перестанет видеть прогон как running и пустит второй свип на том +# же прокси-IP → бан (инцидент 2026-05-31, runs #26+#27). +# Состав проверен по call-site'ам runs.is_cancelled: kit pipeline (city-sweep'ы всех +# площадок и городов, full-load'ы, avito_newbuilding_sweep) + rosreestr_dkp_import +# (scheduler.py). yandex_newbuilding_sweep отмену НЕ опрашивает — поэтому правило не +# «любой *_sweep». Актуально с #2674: до починки фильтра таблица прогонов была пуста +# на всех вкладках, кнопка отмены не рендерилась ни разу и дыра не проявлялась. +_CANCEL_HONORING_EXACT = frozenset({"avito_newbuilding_sweep", "rosreestr_dkp_import"}) +_CANCEL_HONORING_SUBSTRINGS = ("city_sweep", "full_load") + + +def honors_cancel(source: str) -> bool: + """True, если джоб этого source опрашивает отмену и реально остановится.""" + return source in _CANCEL_HONORING_EXACT or any( + key in source for key in _CANCEL_HONORING_SUBSTRINGS + ) + + def is_cancelled(db: Session, run_id: int) -> bool: """Проверить status='cancelled' (cooperative cancel в long-running pipeline).""" row = db.execute( @@ -374,7 +395,25 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) def mark_cancelled(db: Session, run_id: int) -> bool: - """Set status='cancelled' если currently 'running'. Returns True если cancelled.""" + """Set status='cancelled' если currently 'running'. Returns True если cancelled. + + Отказ (False) для source'ов, чей джоб отмену не опрашивает — см. honors_cancel: + там 'cancelled' был бы враньём в статусе и снял бы has_running_run-guard. + Ручки отмены source не проверяют (любая из пяти принимает любой run_id), поэтому + гейт стоит здесь — на общем узле всех пяти. + """ + row = db.execute( + text("SELECT source FROM scrape_runs WHERE id = :run_id"), + {"run_id": run_id}, + ).fetchone() + if row is not None and not honors_cancel(str(row.source)): + logger.warning( + "mark_cancelled отказ: run_id=%d source=%s не опрашивает отмену — " + "задача продолжила бы работать под статусом 'cancelled'", + run_id, + row.source, + ) + return False result = db.execute( text( """ diff --git a/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql index bfb456f1..5b8095e6 100644 --- a/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql +++ b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql @@ -92,8 +92,15 @@ SELECT NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago, (SELECT count(*) FROM v_price_divergence) AS price_disagreements_count; +-- Комментарий из 095 утверждал, что view «refreshed on-demand by /api/v1/admin/ +-- data-quality endpoint». Это неправда с момента переписывания ручки: живой +-- /api/v1/admin/scraper/data-quality строит собственный запрос по listings/houses и +-- этого view не касается, читателей в коде нет ни одного (проверено #2674). PR, +-- тезис которого «ложный показатель хуже отсутствующего», не имеет права нести +-- ложное утверждение о читателе — пишем как есть. COMMENT ON VIEW v_data_quality IS - 'KPI snapshot. Refreshed on-demand by /api/v1/admin/data-quality endpoint (Master Plan sec 8.1). ' + 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): ' + '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. ' '#2674: outliers_flagged убран — is_outlier не писал никто, «выброс» определён только ' 'внутри одной подборки аналогов (estimator._filter_outliers), не на объявлении.'; diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py index 72178b9e..ced39ac9 100644 --- a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py +++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py @@ -187,6 +187,77 @@ def test_list_all_selects_no_dropped_columns(list_all) -> None: assert not still_there, f"list_all всё ещё выбирает дропнутые колонки: {still_there}" +# ══ 4b. Побочка починки фильтра: кнопка отмены открылась на все источники ═════ + + +def test_cancel_flag_true_only_for_jobs_that_poll_cancellation() -> None: + """honors_cancel = ровно те source'ы, чей джоб опрашивает runs.is_cancelled. + + 'yandex_newbuilding_sweep' в наборе НЕ должен быть, хотя и *_sweep: его таск + (app/tasks/yandex_newbuilding_sweep.py) отмену не опрашивает — поэтому правило + не может быть «любой sweep». + """ + honoring = [ + "avito_city_sweep", + "cian_city_sweep_nizhniy_tagil", + "domclick_city_sweep", + "avito_full_load_exhaustive", + "cian_full_load", + "avito_newbuilding_sweep", + "rosreestr_dkp_import", + ] + ignoring = [ + "proxy_healthcheck", + "deactivate_stale_avito", + "refresh_search_matview", + "sber_index_pull", + "yandex_newbuilding_sweep", + "house_imv_backfill", + ] + assert [s for s in honoring if not runs_mod.honors_cancel(s)] == [] + assert [s for s in ignoring if runs_mod.honors_cancel(s)] == [] + + +def test_row_carries_cancellable_so_ui_hides_the_button(client_factory) -> None: + """Строка отдаёт cancellable — без него UI показал бы «Отменить» у любого + running-прогона, включая proxy_healthcheck (1631 из 3245).""" + from unittest.mock import patch + + base = { + "status": "running", + "params": None, + "counters": None, + "total_seen": None, + "new_count": None, + "started_at": None, + "finished_at": None, + "heartbeat_at": None, + "error_text": None, + } + rows = [ + {"run_id": 1, "source": "avito_city_sweep", **base}, + {"run_id": 2, "source": "proxy_healthcheck", **base}, + ] + with patch("app.services.scrape_runs.list_all", return_value=(2, rows)): + r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs") + + assert r.status_code == 200 + assert [row["cancellable"] for row in r.json()["rows"]] == [True, False] + + +def test_mark_cancelled_refuses_non_cooperating_source() -> None: + """Гейт на общем узле всех пяти ручек отмены: 'cancelled' у задачи, которая + отмену не опрашивает, — это враньё в статусе И снятие has_running_run-guard + (второй свип на том же прокси → бан, инцидент 2026-05-31).""" + db = MagicMock() + db.execute.return_value.fetchone.return_value = MagicMock(source="proxy_healthcheck") + + assert runs_mod.mark_cancelled(db, 42) is False + # UPDATE не выполнялся — только SELECT source. + assert db.execute.call_count == 1 + db.commit.assert_not_called() + + # ══ 1-3. Схема: колонок больше нет, и v_data_quality не рапортует выбросы ═════ @@ -211,11 +282,18 @@ def test_latest_v_data_quality_no_longer_reports_outliers() -> None: Red на origin/main: там последним был 095_dead_schema.sql со строкой `(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged` — показатель, который не мог быть ненулевым, потому что колонку не писал никто. + + Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой + DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту + миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический: + деплой применяет файлы отсортированными, последний по имени — последний в проде. """ - marker = "CREATE OR REPLACE VIEW v_data_quality" - creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker in p.read_text(encoding="utf-8")) + marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b") + creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8"))) assert creators, "не найдено ни одной миграции, создающей v_data_quality" latest = creators[-1].read_text(encoding="utf-8") - body = latest.split("CREATE OR REPLACE VIEW v_data_quality")[-1].split(";")[0] + hit = marker.search(latest) + assert hit is not None + body = latest[hit.end() :].split(";")[0] assert "outliers_flagged" not in body assert "is_outlier" not in body diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 2e763b20..3f132e1f 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -150,7 +150,9 @@ def test_scrape_runs_mark_cancelled_returns_bool() -> None: from app.services.scrape_runs import mark_cancelled mock_db = MagicMock() - mock_db.execute.return_value.fetchone.return_value = MagicMock() # row found + # source обязателен: #2674 добавил гейт honors_cancel — отменять можно только то, + # что отмену опрашивает (иначе 'cancelled' у живой задачи + обход has_running_run). + mock_db.execute.return_value.fetchone.return_value = MagicMock(source="avito_city_sweep") result = mark_cancelled(mock_db, 10) assert result is True diff --git a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx index a94ef81e..ceb10b56 100644 --- a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx @@ -16,6 +16,8 @@ export interface ScrapeRunFull { run_id: number; source: string; status: string; + /** Джоб этого источника реально опрашивает отмену (бэкенд, scrape_runs.honors_cancel). */ + cancellable: boolean; params: Record | null; counters: Record | null; total_seen: number | null; @@ -68,7 +70,10 @@ function useScrapeRunSources() { }); } -function useScraperRuns(status: RunStatusFilter, sourceFilter: string, limit = 20) { +// limit=50 (API допускает 200): при выдаче по всем источникам первые 20 строк по +// started_at — на три четверти сердцебиение proxy_healthcheck (1631 прогон из 3245), +// и часовой сбор мог не поместиться на страницу (#2674). +function useScraperRuns(status: RunStatusFilter, sourceFilter: string, limit = 50) { return useQuery({ queryKey: ["scrape-runs", status, sourceFilter, limit], queryFn: () => { @@ -208,7 +213,7 @@ export function RunsTable({ source }: RunsTableProps) {

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

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

{/* Source filter */} @@ -377,7 +382,9 @@ export function RunsTable({ source }: RunsTableProps) { )}
- {r.status === "running" && ( + {/* cancellable — от бэкенда (#2674): у остальных источников + отмена поставила бы статус 'cancelled' работающей задаче. */} + {r.status === "running" && r.cancellable && (
Ссылка доступна до' + f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}
{_mono(today.strftime("%d.%m.%Y"))}
Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))}
Адрес{address}
Год постройки {year_label}
Количество объявлений по аналогичным объектам {_mono(f"{n_total} шт.")}
+ {deals_sourced_note}
Источники данных
{sources_html}
@@ -1344,6 +1409,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra + {reliability_note}

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

diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py index 3ae13ef7..a4033a0f 100644 --- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py +++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py @@ -1,22 +1,55 @@ -"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02). +"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority +RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product +decision, 2026-08-10). -Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings → -headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring -street in the same town swung ±66% on 1-2 different random listings. Каменск- -Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match -either, with no honest refusal surfaced. Первоуральск (0 listings) already fell -back to the (pre-existing) ДКП deals-headline fallback correctly — this gate -routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME, -already-tested path instead of trusting a 1-4-lot median as the headline. +History: + 1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1) + listings sample to a literal zero, forcing the anchor/#oblast-D-deals- + fallback/insufficient_data chain to take over — motivated by a live + Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of + 54 126 ₽/м²). + 2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE — + a thin sample always kept its own median, even when a much more reliable + deals corridor was available. That accidentally REOPENED the exact Серов + bug #oblast-E existed to close. + 3. #oblast-E priority RESTORED (2026-08-10, same day, product correction): + "никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3 + объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in + `_price_from_inputs`'s gate: + - n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected). + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor + exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) → + listings aggregate suppressed to zero, headline ceded to the + #oblast-D deals-headline-fallback chain (original #oblast-E + behaviour, restored). `PricingResult.deals_headline_due_to_thin_ + listings=True` — estimate_quality() adds relaxation label "оценка по + сделкам — мало объявлений рядом" and caps reliability at 'low'. + Listings display cards are NOT hidden (unlike original #oblast-E) — + `listings_clean` stays intact and estimate_quality() still surfaces + them as context even though they no longer drive n_analogs/median. + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor → + #oblast-F: keep the real thin median (never refuse outright). + Real refusal ("недостаточно данных") now happens ONLY at genuine n=0 + (no listings AND no usable anchor/deals) — the never-block requirement + with an honest, priority-ordered source selection. + +`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F +relaxation cascade (room-adjacency / freshness / novostroyki / radius, see +estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in +Layer 1) ever runs the 3-way gate above — `listings` here is whatever that +cascade could find. Two layers: 1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) — - boundary behaviour of the gate itself. + boundary behaviour of the gate itself: the 3-way rule, low-reliability + wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_ + thin_listings bookkeeping. 2. `estimate_quality` integration tests — proves the money-path invariants - that matter to a caller: literal 0 never leaks as a "confident" price, - display `analogs` cards never outnumber what `n_analogs` claims, and the - explanation text describes what actually happened (not a stock "аналогов - не найдено" when some WERE found, just too few). + that matter to a caller: thin+usable-deals routes to the deals corridor + (Серов repro), thin+no-deals keeps its own median, display `analogs` + cards are shown either way, and the #oblast-F room-adjacency relaxation + (studio↔1-комн) actually grows a thin sample and is reported via + `AggregatedEstimate.relaxations` / `reliability`. """ from __future__ import annotations @@ -103,15 +136,14 @@ def test_threshold_is_five_not_lower() -> None: assert HEADLINE_LISTINGS_MIN_N == 5 -def test_four_listings_below_threshold_suppressed_no_fallback() -> None: - """n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state, - NOT the naive median of 4 listings.""" +def test_four_listings_below_threshold_kept_not_suppressed() -> None: + """#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision + 2026-08-10 — never zero out a thin-but-real sample), just flagged low.""" pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0])) - assert pr.median_ppm2 == 0.0 - assert pr.median_price == 0 - assert pr.n_analogs == 0 - assert pr.range_low == 0 - assert pr.range_high == 0 + assert pr.median_ppm2 == 215_000.0 + assert pr.n_analogs == 4 + assert pr.median_price == round(215_000.0 * 45.0) + assert pr.confidence == "low" def test_five_listings_at_threshold_not_suppressed() -> None: @@ -122,18 +154,21 @@ def test_five_listings_at_threshold_not_suppressed() -> None: assert pr.median_price == round(210_000.0 * 45.0) -def test_one_listing_below_threshold_suppressed() -> None: - """n=1 — the sharpest form of the Серов bug (a single random lot deciding - the whole headline) — must be suppressed exactly like n=4.""" +def test_one_listing_below_threshold_kept_not_suppressed() -> None: + """#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot) + median rather than being zeroed; confidence stays 'low'.""" pr = _call(listings=_lots([200_000.0])) - assert pr.median_ppm2 == 0.0 - assert pr.n_analogs == 0 + assert pr.median_ppm2 == 200_000.0 + assert pr.n_analogs == 1 + assert pr.confidence == "low" def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: - """n=3 listings (thin) + a usable ДКП corridor → headline comes from the - deal corridor median, NOT the 3-listing median (live Серов repro: 3 - listings gave 42 391 vs the honest ДКП-based ~54 126).""" + """#oblast-E priority RESTORED (2026-08-10 product correction): a thin + (n=3) listings sample must NOT outrank a usable ДКП deals corridor — this + is the exact live Серов repro #oblast-E exists for (3 noisy listings gave + 42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline + comes from the deal corridor median, NOT the 3-listing median.""" dkp_raw = { "count": 54, "low_ppm2": 44_000, @@ -151,12 +186,23 @@ def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: ) assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline" assert pr.confidence == "low" + assert pr.deals_headline_due_to_thin_listings is True + assert pr.listings_clean, "listings_clean must stay intact — display cards still show them" + # #4: explanation must not falsely claim "рядом нет объявлений" (some WERE + # found, just ceded priority to the more reliable deals corridor) and must + # NOT also carry the separate "Оценка построена по N аналогам" thin-kept + # wording (that phrasing is reserved for the no-usable-corridor branch). + assert pr.explanation is not None + assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "сделкам росреестра" in pr.explanation.lower() + assert "оценка построена по 3" not in pr.explanation.lower() -def test_thin_sample_with_insufficient_deals_stays_zero() -> None: +def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None: """n=3 listings (thin) + a ДКП corridor that is ITSELF too thin - (< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero, - not a fabricated number from either side.""" + (< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so + #oblast-F's never-block rule applies: the real listings median is kept + rather than refusing (neither source alone would justify a hard zero).""" dkp_raw = { "count": 1, "low_ppm2": 40_000, @@ -165,49 +211,51 @@ def test_thin_sample_with_insufficient_deals_stays_zero() -> None: "period_months": 12, } pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw) - assert pr.median_ppm2 == 0.0 - assert pr.median_price == 0 - assert pr.n_analogs == 0 + assert pr.median_ppm2 == 42_391.0 + assert pr.n_analogs == 3 + assert pr.deals_headline_due_to_thin_listings is False -def test_thin_sample_explanation_is_honest_about_count() -> None: - """The explanation for a thin-but-nonzero sample must say HOW MANY listings - were found (not the generic 'ничего не найдено' text used for a genuine - zero-listing case) — #4 in the task: explanation must match reality.""" +def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None: + """#4 (task spec): the explanation for a thin-but-real sample must read as + "small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для + оценки по рынку" copy, and NOT the generic zero-analogs text.""" pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2 assert pr.explanation is not None assert "2" in pr.explanation - assert "недостаточно" in pr.explanation.lower() - # Must NOT reuse the "nothing found at all" copy — 2 listings WERE found. + assert "выборка мала" in pr.explanation.lower() + assert "точность снижена" in pr.explanation.lower() + assert "минимум для оценки по рынку" not in pr.explanation.lower() assert "не найдено аналогов" not in pr.explanation.lower() -def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None: - """#4: once the ДКП fallback fires for a thin (not zero) sample, the - explanation must not falsely claim 'рядом нет объявлений' — some WERE - found, just not enough to trust.""" +def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None: + """Control: the #oblast-D deals-headline-fallback path is UNCHANGED for + GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin + case, not the true-zero case, which still needs a fallback source.""" dkp_raw = { - "count": 20, - "low_ppm2": 40_000, - "median_ppm2": 60_000, - "high_ppm2": 80_000, + "count": 54, + "low_ppm2": 44_000, + "median_ppm2": 65_957, + "high_ppm2": 89_000, "period_months": 12, } - pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw) + pr = _call(listings=[], dkp_raw=dkp_raw) + assert pr.median_ppm2 == 65_957.0 + assert pr.n_analogs == 0 + assert pr.confidence == "low" assert pr.explanation is not None - assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "рядом нет актуальных объявлений" in pr.explanation.lower() assert "сделкам росреестра" in pr.explanation.lower() -def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None: - """Regression guard: the gate must suppress the AGGREGATE (median/n_analogs) - without clearing `listings_clean` itself — the same-building anchor's own - ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell - "genuinely zero nearby listings" from "some nearby, just too few to trust - as headline", and conflating the two was caught regressing - test_estimator_split_corridor_1871.py during this change.""" +def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None: + """listings_clean stays intact (unchanged invariant — same-building anchor's + ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is + the REAL count (not zeroed) while listings_headline_thin_n still marks the + sample as thin for the low-reliability note upstream.""" pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0])) - assert pr.n_analogs == 0 + assert pr.n_analogs == 3 assert len(pr.listings_clean) == 3 assert pr.listings_headline_thin_n == 3 @@ -219,6 +267,42 @@ def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None: assert pr.listings_headline_thin_n == 0 +def test_repair_coefficient_now_applies_to_thin_sample() -> None: + """#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a + thin sample because the headline was already zeroed (applying it would be a + no-op). Now that the real median is kept, the coefficient must apply.""" + pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin + pr = _price_from_inputs( + listings=_lots([200_000.0, 210_000.0]), + area_m2=45.0, + rooms=2, + repair_state="excellent", + floor=5, + total_floors=9, + target_year=None, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=[], + anchor_tier_fetched=None, + dkp_raw=None, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=lambda _appm2: (None, None), + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + assert ( + pr.median_price != pr_no_repair.median_price + ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample" + + # ───────────────────────────────────────────────────────────────────────────── # Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) # ───────────────────────────────────────────────────────────────────────────── @@ -261,24 +345,31 @@ def _serov_payload() -> Any: def _run_estimate( *, - analogs: list[dict[str, Any]], + analogs: list[dict[str, Any]] | None = None, dkp_raw: dict[str, Any] | None, + fetch_analogs_side_effect: Any = None, + payload: Any = None, + geo: GeocodeResult | None = None, ) -> Any: from app.services.estimator import estimate_quality db = MagicMock() - payload = _serov_payload() + payload = payload or _serov_payload() + geo = geo or _geo() + + fetch_analogs_kwargs: dict[str, Any] = ( + {"side_effect": fetch_analogs_side_effect} + if fetch_analogs_side_effect is not None + else {"return_value": (list(analogs or []), False, "W")} + ) async def _run() -> Any: with ( - patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), - patch( - "app.services.estimator._fetch_analogs", - return_value=(list(analogs), False, "W"), - ), + patch("app.services.estimator._fetch_analogs", **fetch_analogs_kwargs), patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), patch("app.services.estimator._fetch_deals", return_value=[]), patch( @@ -301,40 +392,47 @@ def _run_estimate( return anyio.run(_run) -def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None: - """Каменск-Уральский-style repro: thin listings, no usable ДКП corridor — - median_price_rub must be 0 AND insufficient_data must be True TOGETHER - (the AggregatedEstimate.insufficient_data computed_field invariant that - stops a literal 0 ₽ reaching the user as a confident number).""" +def test_e2e_thin_sample_no_relaxation_help_keeps_real_median() -> None: + """#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always + returns the SAME 2 listings regardless of relaxation params (none of them + help) — median_price_rub must be the REAL non-zero 2-listing median, + insufficient_data False, n_analogs=2, confidence='low', reliability + 'very_low' (n<3), relaxations empty (nothing actually helped).""" analogs = [ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), ] est = _run_estimate(analogs=analogs, dkp_raw=None) - assert est.median_price_rub == 0 - assert est.insufficient_data is True - assert est.n_analogs == 0 + assert est.median_price_rub == round(205_000.0 * 45.0) + assert est.insufficient_data is False + assert est.n_analogs == 2 assert est.confidence == "low" + assert est.relaxations == [] + assert est.reliability == "very_low" -def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None: - """The 2 thin listings must NOT be surfaced as `analogs` display cards while - n_analogs reports 0 — that would be the same dishonesty (confident-looking - UI) this whole gate exists to remove.""" +def test_e2e_thin_sample_display_cards_match_n_analogs() -> None: + """#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin + sample — they must match n_analogs exactly (both = 2), never hidden.""" analogs = [ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), ] est = _run_estimate(analogs=analogs, dkp_raw=None) - assert est.n_analogs == 0 - assert est.analogs == [] + assert est.n_analogs == 2 + assert len(est.analogs) == 2 def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: - """Live Серов repro (n=3 scraped listings, wide ДКП corridor available): - headline must come from the deal corridor, not the noisy 3-listing median, - and the estimate must be honestly non-'insufficient' (a real number, low - confidence, deals-sourced).""" + """Live Серов repro (n=3 scraped listings, wide ДКП corridor available) — + #oblast-E priority RESTORED: headline must come from the deal corridor, + not the noisy 3-listing median. Also proves the #4 task-spec requirements + layered on top of the restored priority: the estimate is honestly non- + 'insufficient' (a real number, low confidence), reliability is capped at + 'low' (not 'very_low' — a 54-deal corridor is real signal), the + relaxation label names the source switch, AND the 3 thin listings are + still shown as display cards (not discarded) even though they no longer + drive n_analogs/median.""" analogs = [ _make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"), _make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"), @@ -354,12 +452,15 @@ def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: assert est.confidence == "low" assert est.confidence_explanation is not None assert "сделкам росреестра" in est.confidence_explanation.lower() + assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'" + assert "оценка по сделкам — мало объявлений рядом" in est.relaxations + assert len(est.analogs) == 3, "thin listings must still surface as display cards" def test_e2e_sufficient_five_analogs_unaffected_control() -> None: """Control (mirrors the Екатеринбург prod check in the PR): a sample that clears the threshold is priced exactly as before — headline is the real - listings median, all 5 analogs counted.""" + listings median, all 5 analogs counted, no relaxations needed.""" analogs = [ _make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"), @@ -371,3 +472,78 @@ def test_e2e_sufficient_five_analogs_unaffected_control() -> None: assert est.median_price_per_m2 == 210_000 assert est.n_analogs == 5 assert est.insufficient_data is False + assert est.relaxations == [] + assert est.reliability == "low" # n=5 falls in the 3..7 bucket + + +def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None: + """#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin + sample (studio-adjacent building, live prod repro Академика Парина 46/5) → + cascade retries with rooms IN (0,1) and finds a trustworthy sample there. + Asserts: studios pulled in, `relaxations` names it, real non-zero median, + reliability downgraded to 'low' (thin base sample).""" + from app.schemas.trade_in import TradeInEstimateInput + + exact_rooms1 = [ + _make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0), + _make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0), + ] + studio_pool = [ + *exact_rooms1, + _make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0), + _make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0), + _make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0), + ] + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1: + return list(studio_pool), False, "W" + return list(exact_rooms1), False, "W" + + geo = GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5", + provider="nominatim", + ) + payload = TradeInEstimateInput( + address="ЕКБ, ул. Парина, 46/5", + area_m2=23.1, + rooms=1, + ) + + est = _run_estimate( + dkp_raw=None, + fetch_analogs_side_effect=_fetch_analogs_stub, + payload=payload, + geo=geo, + ) + + assert "учтены студии" in est.relaxations + assert est.median_price_rub > 0 + assert est.reliability == "low" + assert est.n_analogs == 5 + + +def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None: + """#oblast-F step (d) contract: when the user explicitly picked radius_m + (#2044), the cascade must NOT auto-expand past it — mirrors the existing + radius-fallback contract above (no auto-expansion beyond user's choice).""" + from app.schemas.trade_in import TradeInEstimateInput + + thin = [ + _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), + _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), + ] + payload = TradeInEstimateInput( + address="Серов, ул. Ленина, 5", + area_m2=45.0, + rooms=2, + floor=5, + total_floors=9, + city_hint="Серов", + radius_m=1500, + ) + est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload) + assert not any("радиус расширен" in r for r in est.relaxations) + assert est.search_radius_m == 1500 diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index da5722ec..7d6d21ed 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -423,6 +423,86 @@ def test_build_listings_page_none_year_built_no_crash() -> None: assert "РЫНОК КВАРТИР" in html +# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ──────── +# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py +# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list +# (thin listings kept as reference cards) used to print "0 шт." above a +# non-empty examples table — a client-visible contradiction that leaked into +# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_ +# listings_note_html / _reliability_note_html. + + +def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None: + """The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' — + falls back to the actually-shown population (3) and adds an honest + deals-sourced footnote.""" + analogs = [ + _analog(address="ул. Льва Толстого, 8А"), + _analog(address="ул. Кирова, 4"), + _analog(address="ул. Льва Толстого, 34"), + ] + est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "0 шт." not in html + assert "3 шт." in html + assert "Оценка построена по зарегистрированным сделкам Росреестра" in html + assert "почти нет" in html + + +def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None: + """Control: genuinely zero listings (no cards to show either) — '0 шт.' is + honest here, and the deals-sourced footnote (which explains a MISMATCH) + must NOT appear since there is nothing to reconcile.""" + est = _estimate(n_analogs=0, analogs=[]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "0 шт." in html + assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html + + +def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None: + """Control/regression guard for the max() choice: a healthy sample where + n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's + own top-10 cap) must keep printing the full honest count (15 шт.), NOT + silently understate it to len(analogs) (10 шт.).""" + analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)] + est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "15 шт." in html + assert "10 шт." not in html + + +def test_listings_page_relaxations_warning_shown_with_labels() -> None: + """relaxations non-empty → warning block present, names the labels, and + reliability != 'ok' — mirrors what the web LowConfidenceBanner already + shows (see AggregatedEstimate docstring).""" + est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low") + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." in html + assert "учтены студии" in html + assert "радиус расширен до 3000 м" in html + + +def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None: + """reliability != 'ok' but relaxations is empty (cascade couldn't grow a + thin sample, estimator.py #oblast-F) → warning block still shown, with a + fallback sentence (not an empty label list).""" + est = _estimate(n_analogs=2, reliability="very_low", relaxations=[]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." in html + assert "небольшой выборке" in html + + +def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None: + """Control: the common/unrelaxed case (reliability='ok' default, no + relaxations) — no warning block at all, byte-identical to the report + before these fields existed.""" + est = _estimate() + assert est.reliability == "ok" + assert est.relaxations == [] + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." not in html + + def test_build_deals_page_none_year_built_no_crash() -> None: snap = dict(_SNAPSHOT) snap["year_built"] = None diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index c1ea1249..003883e1 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -107,6 +107,64 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N assert extract_street_name(address) == expected +@pytest.mark.parametrize( + "address,expected", + [ + # Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a + # trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading + # postal index + admin parts ("620105, Свердловская обл, г + # Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required + # the dot → keyword never matched on ANY DaData address → street-deals + # / sales-vs-listings endpoints silently returned empty for every + # DaData-geocoded request, not just this one. + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "ул Академика Парина, д 46/5", + "Академика Парина", + ), + # Same address, WITH the dot — must give the identical result (dot + # optional, not dot-forbidden). + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "ул. Академика Парина, д 46/5", + "Академика Парина", + ), + # Same address, full word "улица" — the alternation-order/backtracking + # concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave + # "ица ..." behind. + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "улица Академика Парина, д 46/5", + "Академика Парина", + ), + # Without the leading postal index — same admin prefix otherwise. + ( + "Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5", + "Академика Парина", + ), + # Bare street+house, no admin prefix at all. + ("ул Академика Парина, д 46/5", "Академика Парина"), + # Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр). + ("г Екатеринбург, пр Ленина, 5", "Ленина"), + ("г Екатеринбург, пер Красный, 4", "Красный"), + ("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"), + # "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression). + ("Екатеринбург, ул. Малышева, 1", "Малышева"), + ("Екатеринбург, ул Малышева, 1", "Малышева"), + ("Екатеринбург, улица Малышева, 1", "Малышева"), + ], +) +def test_extract_street_name_dadata_no_dot_abbreviations( + address: str | None, expected: str | None +) -> None: + """#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses + use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр") + — _STREET_KW_RE must match them exactly like the dotted forms.""" + from app.services.estimator import extract_street_name + + assert extract_street_name(address) == expected + + # ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 49cc2846..f0164c23 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -20,6 +20,7 @@ import TopNav from "@/components/trade-in/v2/TopNav"; import HeroBar from "@/components/trade-in/v2/HeroBar"; import ParamsPanel from "@/components/trade-in/v2/ParamsPanel"; import ResultPanel from "@/components/trade-in/v2/ResultPanel"; +import { LowConfidenceBanner } from "@/components/trade-in/v2/LowConfidenceBanner"; import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary"; import { LeadForm } from "@/components/trade-in/v2/LeadForm"; import { Footer } from "@/components/trade-in/v2/Footer"; @@ -584,6 +585,18 @@ export default function TradeInV2Page() { // — no hydration drift, same reason the PDF control is gated behind `mounted`. const hasEstimate = mounted && estimate != null && !insufficient; + // fix (never-block estimate) — reliability/relaxations are optional on the + // wire (old/cached estimates predate the backend fields), default to the + // "nothing to disclose" values so a stale response never fabricates a + // warning. LowConfidenceBanner mounts above the result whenever the sample + // was thin (reliability !== "ok") or the backend had to relax the search to + // produce a price at all (relaxations.length > 0) — never on insufficient + // (no price at all — that stays InsufficientPanel, no banner to layer over). + const reliability = estimate?.reliability ?? "ok"; + const relaxations = estimate?.relaxations ?? []; + const showLowConfidenceBanner = + !insufficient && (reliability !== "ok" || relaxations.length > 0); + // ── Mapped presentation data (memoised so nav/drawer toggles don't recompute // geometry). ────────────────────────────────────────────────────────── const report = useMemo( @@ -772,12 +785,31 @@ export default function TradeInV2Page() { /> ); } else if (estimate && !insufficient && resultPanelData) { + // Banner is a sibling ABOVE ResultPanel, not a change to ResultPanel + // itself — the wrapper only replaces the direct grid child; ResultPanel's + // own markup/props are untouched from before this fix. middleContent = ( - +
+ {showLowConfidenceBanner && ( + + )} + +
); } else if (estimate && insufficient) { middleContent = ; diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index b55e7e62..1b89abe9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -185,6 +185,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const [enrichRepairState, setEnrichRepairState] = useState(""); // Фото первого аналога с картинкой — вместо пустого серого плейсхолдера. const heroPhoto = estimate.analogs.find((a) => a.photo_url)?.photo_url ?? null; + // fix (v1 stale-tail) — n_analogs=0 больше не значит «аналогов нет»: бэкенд + // может посчитать headline по зарегистрированным сделкам ДКП, но всё равно + // отдать тонкую выборку объявлений в estimate.analogs (её же показывает + // ListingsCard ниже на этой странице) — «0 аналогов» рядом с видимыми + // карточками было бы прямым противоречием. Тон — как у v2 LowConfidenceBanner. + const analogsCaption = + estimate.n_analogs > 0 || estimate.analogs.length === 0 + ? `${estimate.n_analogs} аналогов` + : "оценка построена по зарегистрированным сделкам"; // Расчёт ширины для price bar (50% = середина): медиана внутри min/max const span = hi - lo; const medianPctRaw = span > 0 ? ((m - lo) / span) * 100 : 50; @@ -292,7 +301,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {heroPhoto ? `фото аналога${estimate.sources_used[0] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}` : estimate.sources_used.length > 0 - ? `${sourceLabel(estimate.sources_used[0])} · ${estimate.n_analogs} аналогов` + ? `${sourceLabel(estimate.sources_used[0])} · ${analogsCaption}` : "Нет фото"}
diff --git a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx index b78b0f4a..632e3863 100644 --- a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx @@ -118,7 +118,12 @@ export function ListingsCard({ estimate, estimateId }: Props) {
Объявлений по аналогам
- {estimate.n_analogs} + {/* fix (v1 stale-tail) — n_analogs=0 больше не значит "объявлений + нет": бэкенд может посчитать headline по сделкам ДКП, но + всё равно отдать тонкую выборку объявлений в analogs (тот же + массив рендерит таблица ниже, см. `lots`). Показываем реальную + отображаемую популяцию, а не сырой n_analogs, когда он 0. */} + {estimate.n_analogs > 0 ? estimate.n_analogs : lots.length} шт
из {estimate.sources_used.length} источников
@@ -271,8 +276,21 @@ export function ListingsCard({ estimate, estimateId }: Props) {
- Показано {lots.length} из {estimate.n_analogs}{" "} - объявлений · отсортировано по расстоянию + {/* fix (v1 stale-tail) — see count-strip comment above: n_analogs=0 + with a non-empty lots[] is the deals-fallback branch, not "0 + analogs shown". Drop the false "из 0" denominator and disclose + the deals basis instead (same tone as v2 LowConfidenceBanner). */} + {estimate.n_analogs > 0 ? ( + <> + Показано {lots.length} из{" "} + {estimate.n_analogs} объявлений · отсортировано по расстоянию + + ) : ( + <> + Показано {lots.length} объявлений · + оценка построена по зарегистрированным сделкам · отсортировано по расстоянию + + )}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx new file mode 100644 index 00000000..a1275bf1 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx @@ -0,0 +1,112 @@ +"use client"; + +// LowConfidenceBanner — fix (never-block estimate). Renders ABOVE the result +// block (ResultPanel) whenever the backend flags the analog sample as thin +// (`reliability !== "ok"`) or had to relax the search just to produce a +// price at all (`relaxations.length > 0`). It never blocks the estimate — +// v2/page.tsx's `insufficient` gate (InsufficientPanel) still fires only +// when there is truly no price (`insufficient_data`, median_price_rub <= 0). +// +// Root incident this fixes: a 23.1 m² studio in Екатеринбург got +// median_price_rub=0 purely because studios were being folded into 1-room +// analogs (see ParamsPanel's initRoomsLabel/rooms=0 fix) and the UI walled +// the whole estimate behind "недостаточно данных". Product call: always show +// the number, with an honest, visible caveat instead of a hard block. + +import { tokens } from "./tokens"; +import { pluralRu } from "./mappers"; +import type { ReliabilityLevel } from "@/types/trade-in"; + +interface LowConfidenceBannerProps { + // Caller (v2/page.tsx) resolves the optional backend fields to concrete + // values (reliability ?? "ok", relaxations ?? []) and decides whether to + // mount this component at all — kept required here so an omitted prop is a + // TS error, not a silent fallback (same contract as ResultPanel/ObjectSummary). + nAnalogs: number; + reliability: ReliabilityLevel; + relaxations: string[]; +} + +// Same one-off "danger" tint pairing already used elsewhere in v2 +// (AnalyticsView's sell-time tier tiles: rgba fill + soft hex border, no +// direct token equivalent) — kept identical here instead of inventing a new +// hex; the actual label colour is the real tokens.danger semantic token. +const bannerBg = "rgba(214,90,90,.08)"; +const bannerBorder = "1px solid #e6c3c3"; + +export function LowConfidenceBanner({ + nAnalogs, + reliability, + relaxations, +}: LowConfidenceBannerProps) { + const title = + reliability === "very_low" + ? "Данные ограничены — оценка ориентировочная" + : "Мало аналогов — точность снижена"; + + // The backend's deals-cession label duplicates the prose we already render + // in the nAnalogs === 0 branch — drop it there so the caveat is stated once. + const visibleRelaxations = + nAnalogs > 0 + ? relaxations + : relaxations.filter((r) => !r.startsWith("оценка по сделкам")); + + return ( +
+
+ {title} +
+ {/* Body text stays on the high-contrast ink token (not the danger + token) — tokens.danger (#cd6868) over this pale tint fails AA for + body copy, ink2 is the codebase's established accessible-contrast + choice (see tokens.ts comment block). */} +
+ {/* n_analogs === 0 with a price on screen is NOT an empty result: it is + the deals-corridor headline (backend cedes the headline to ДКП when + the listings sample is thin). Saying «найдено 0 аналогов» there + would contradict both the shown price and the listing cards below, + which are still rendered from the thin sample. */} + {nAnalogs > 0 ? ( + <> + Найдено {nAnalogs}{" "} + {pluralRu(nAnalogs, ["аналог", "аналога", "аналогов"])} — оценка + может быть неточной. + + ) : ( + <> + Оценка построена по зарегистрированным сделкам — подходящих + объявлений рядом почти нет. + + )} + {visibleRelaxations.length > 0 && ( + <> + {" "} + Для расчёта расширили параметры поиска:{" "} + {visibleRelaxations.join(", ")}. + + )} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 9d6b75a0..f6196e41 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -580,11 +580,15 @@ interface ParamsPanelProps { searchRadiusM?: number | null; } -// rooms number -> dropdown label. The design has no «Студия» option, so studio -// (0) and 1-room both map to "1"; >=5 collapses to "5+". null -> design default. +// rooms number -> dropdown label. fix (never-block estimate) — «Студия» +// (rooms=0) is its own option, no longer collapsed into "1" (that collapse +// sent rooms:1 on submit for real studios — root cause of a prod incident +// where a 23.1 m² studio got a false "недостаточно данных"). >=5 still +// collapses to "5+". null -> design default. function initRoomsLabel(rooms: number | null | undefined): string { if (rooms == null) return "2"; if (rooms >= 5) return "5+"; + if (rooms === 0) return "Студия"; if (rooms <= 1) return "1"; return String(rooms); } @@ -919,7 +923,7 @@ export default function ParamsPanel({ onSubmit?.({ address: trimmedAddress, area_m2: areaNum, - rooms: rooms === "5+" ? 5 : Number(rooms), + rooms: rooms === "5+" ? 5 : rooms === "Студия" ? 0 : Number(rooms), floor: floor.trim() ? Number(floor) : null, total_floors: totalFloors.trim() ? Number(totalFloors) : null, year_built: year.trim() ? Number(year) : undefined, diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx index 3c836620..35cafb7d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx @@ -419,10 +419,22 @@ export function SourcesMap({ estimate }: Props) { {/* Fix #1 — analogPoints is the top-10 display sample (only what the backend returns coords for); estimate.n_analogs is the true total used in the calc. "N из M" mirrors the deals-table "Показано N из - M" pattern so this never contradicts the market KPI band above. */} + M" pattern so this never contradicts the market KPI band above. + fix (v2 stale-tail) — n_analogs=0 with analogPoints non-empty is + the deals-fallback branch (headline built from ДКП сделки, thin + listing sample still plotted) — "N из 0" would read as a lie. + Drop the denominator and disclose the basis instead; kept short + (map caption, not a paragraph) but same tone as elsewhere in + this fix (HeroSummary/ListingsCard/LowConfidenceBanner). */} Объявлений: {analogPoints.length} - {" из "} - {estimate.n_analogs} + {estimate.n_analogs > 0 || analogPoints.length === 0 ? ( + <> + {" из "} + {estimate.n_analogs} + + ) : ( + " · по сделкам ДКП" + )} {dealPoints.length > 0 && ( <> {" · "}сделок: {dealPoints.length} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index e153e3ec..2af10439 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -2105,7 +2105,12 @@ export function mapSources( const marketAds: MarketAds = { kpi: { - count: e != null ? String(e.n_analogs) : "—", + // fix (v2 stale-tail) — n_analogs=0 with adRows non-empty is the + // deals-fallback branch (headline built from ДКП сделки, thin listing + // sample still shown in the table right below this KPI tile) — a bare + // "0" here would directly contradict visible rows. Fall back to the + // actual displayed population (same fix as ListingsCard's count-strip). + count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—", median: e != null ? fmtMln(e.median_price_rub) : "—", ppm: e != null && Number.isFinite(e.median_price_per_m2) @@ -2156,9 +2161,15 @@ export function mapSources( "возможных выбросов исключено", ])} из расчёта разброса` : ""; + // fix (v2 stale-tail) — n_analogs=0 with a non-empty adRows[] is the + // deals-fallback branch, not "0 analogs shown" (see marketAds.kpi.count + // above). Drop the false "из 0" denominator and disclose the deals basis + // instead, same tone as HeroSummary/ListingsCard/LowConfidenceBanner. const adsFootnote = e != null - ? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}` + ? e.n_analogs > 0 + ? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}` + : `Показано ${adRows.length} объявлений · оценка построена по зарегистрированным сделкам${outlierNote}` : undefined; return { diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts b/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts index 7e955710..9c01b5a4 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts @@ -13,7 +13,12 @@ import type { DropdownOptions } from "./types"; // ---- INPUTS / DROPDOWNS --------------------------------------------------- export const dropdownOptions: DropdownOptions = { - rooms: ["1", "2", "3", "4", "5+"], + // fix (never-block estimate) — «Студия» первым пунктом, маппится на rooms=0 + // (см. initRoomsLabel / handleSubmit в ParamsPanel.tsx). Раньше студии + // схлопывались в «1» → на сабмите уходил rooms:1 вместо rooms:0 — корневая + // причина прод-инцидента с 23.1 м² студией в ЕКБ (median=0 → ложная + // «недостаточно данных»). + rooms: ["Студия", "1", "2", "3", "4", "5+"], houseType: [ "Не указано", "Панельный", diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 94f1dea2..38ecd503 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -42,6 +42,13 @@ export function asRepairState(v: string | null | undefined): RepairState | undef export type ConfidenceLevel = "low" | "medium" | "high"; +// fix (never-block estimate) — сигнал бэкенда о качестве выборки помимо +// insufficient_data (которое теперь true ТОЛЬКО когда цены реально нет, +// median_price_rub <= 0). "low"/"very_low" → UI показывает LowConfidenceBanner +// НАД оценкой вместо блокировки. Optional: старый бэкенд/кешированные оценки +// поле не отдают → UI фолбэк на "ok" (см. LowConfidenceBanner.tsx). +export type ReliabilityLevel = "ok" | "low" | "very_low"; + // Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2. export type AddressPrecision = "house" | "street" | "approximate"; @@ -168,6 +175,14 @@ export interface AggregatedEstimate { confidence_explanation: string | null; n_analogs: number; insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных) + // fix (never-block estimate) — оценка теперь показывается всегда, пока цена + // посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по + // сделкам ДКП). relaxations/reliability — как именно бэкенд ослабил поиск, + // чтобы всё-таки посчитать цену; UI рендерит их в LowConfidenceBanner НАД + // оценкой вместо блокирующей панели «недостаточно данных». Оба optional + + // с дефолтами при чтении ([] / "ok") — старый бэкенд их не отдаёт. + relaxations?: string[]; // готовые RU-подписи, напр. ["учтены студии", "радиус расширен до 3000 м"] + reliability?: ReliabilityLevel; period_months: number; // 24 analogs: AnalogLot[]; // top 5-10 actual_deals: AnalogLot[]; // last 12 mo From 8423af5dd55ddd5136a86ed71874ed5fe4f72947 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 10 Aug 2026 16:00:54 +0000 Subject: [PATCH 123/130] =?UTF-8?q?feat(tradein):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=B0?= =?UTF-8?q?=20=E2=80=94=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B2=D0=B0=D0=BB,=20PDF,=20/versions=20(#2824)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/deploy-tradein.yml | 48 +++++++ tradein-mvp/CHANGELOG.md | 46 +++++++ tradein-mvp/VERSION | 1 + tradein-mvp/backend/Dockerfile | 24 ++++ tradein-mvp/backend/app/api/v1/version.py | 20 +++ tradein-mvp/backend/app/core/rbac.py | 4 + tradein-mvp/backend/app/core/version.py | 83 ++++++++++++ tradein-mvp/backend/app/main.py | 2 + .../app/services/exporters/trade_in_pdf.py | 36 ++++-- tradein-mvp/backend/tests/test_version_api.py | 99 +++++++++++++++ tradein-mvp/frontend/Dockerfile | 30 +++++ tradein-mvp/frontend/src/app/v2/layout.tsx | 10 ++ .../frontend/src/app/versions/page.tsx | 120 ++++++++++++++++++ .../src/components/trade-in/VersionFooter.tsx | 80 ++++++++++++ .../src/components/trade-in/v2/Footer.tsx | 17 +-- .../src/components/trade-in/v2/TopNav.tsx | 89 +++++++++---- .../src/components/trade-in/v2/ui-config.ts | 8 +- tradein-mvp/frontend/src/lib/buildInfo.ts | 37 ++++++ tradein-mvp/frontend/src/lib/changelog.ts | 106 ++++++++++++++++ tradein-mvp/frontend/src/types/version.ts | 34 +++++ 20 files changed, 845 insertions(+), 49 deletions(-) create mode 100644 tradein-mvp/CHANGELOG.md create mode 100644 tradein-mvp/VERSION create mode 100644 tradein-mvp/backend/app/api/v1/version.py create mode 100644 tradein-mvp/backend/app/core/version.py create mode 100644 tradein-mvp/backend/tests/test_version_api.py create mode 100644 tradein-mvp/frontend/src/app/versions/page.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx create mode 100644 tradein-mvp/frontend/src/lib/buildInfo.ts create mode 100644 tradein-mvp/frontend/src/lib/changelog.ts create mode 100644 tradein-mvp/frontend/src/types/version.ts diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index ee062b18..d45dd831 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -30,11 +30,26 @@ jobs: infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }} # Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE # в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend. + app_version: ${{ steps.build-meta.outputs.app_version }} + build_sha: ${{ steps.build-meta.outputs.build_sha }} + build_date: ${{ steps.build-meta.outputs.build_date }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + # Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник + # правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки — + # проброшены как build-args в build-backend/build-frontend ниже (см. + # tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile). + # Считается ОДИН раз здесь, а не в каждой job отдельно. + - name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE) + id: build-meta + run: | + echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT" + echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # Resolve base SHA: read last-successfully-deployed SHA from the VPS host file. # The file is written by the deploy job on every successful deploy. # Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD, @@ -107,8 +122,20 @@ jobs: # scheduler_main импортирует пакет) — kit-only изменение обязано # пересобрать образ, иначе деплой рестартует контейнеры на старом. - 'tradein-mvp/packages/scraper-kit/**' + # APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend + # ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ + # правок кода обязан пересобрать образ, иначе GET /version и колонтитул + # PDF продолжат отдавать старое значение при формально «успешном» деплое. + - 'tradein-mvp/VERSION' frontend: - 'tradein-mvp/frontend/**' + # NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же + # причина, что у backend выше. + - 'tradein-mvp/VERSION' + # /versions статически запекает CHANGELOG.md в билд (см. + # frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ + # frontend/** иначе не долетала бы до образа. + - 'tradein-mvp/CHANGELOG.md' browser: - 'tradein-mvp/browser/**' infra: @@ -211,6 +238,13 @@ jobs: context: ./tradein-mvp file: ./tradein-mvp/backend/Dockerfile push: true + # APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см. + # backend/Dockerfile ARG→ENV) — читает app/core/version.py: + # GET /api/v1/trade-in/version + колонтитул PDF-отчёта. + build-args: | + APP_VERSION=${{ needs.changes.outputs.app_version }} + BUILD_SHA=${{ needs.changes.outputs.build_sha }} + BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | @@ -236,6 +270,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context + # (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста, + # поэтому копируем внутрь ДО build. /versions статически запекает его + # содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage + # комментарий). Не влияет на кэш другого шага — читается только этим. + - name: Stage CHANGELOG.md into frontend build context + run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md + - name: Build & push tradein-frontend uses: docker/build-push-action@v6 with: @@ -246,9 +288,15 @@ jobs: # (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в # dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница # уходит в notFound (404), не индексируется и не краулится. + # NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js + # инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит, + # см. frontend/Dockerfile комментарий у соответствующих ARG). build-args: | NEXT_PUBLIC_BASE_PATH=/trade-in NEXT_PUBLIC_API_BASE_URL=/trade-in + NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} + NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} + NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | diff --git a/tradein-mvp/CHANGELOG.md b/tradein-mvp/CHANGELOG.md new file mode 100644 index 00000000..0e70377d --- /dev/null +++ b/tradein-mvp/CHANGELOG.md @@ -0,0 +1,46 @@ +# История версий «МЕРА» + +Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и +[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно +`## ` (машинно читается страницей истории версий). + +## 2.1.0 — 2026-08-10 + +Первая версия с явным версионированием. Номер продолжает ряд, который до этого +показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние +отчёты. + +### Добавлено + +- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и + реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень + уверенности в оценке. +- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав + аналогов и сделок, формирование выкупной стоимости. +- Аналитика по дому — история размещений объявлений и продаж в доме. +- История прошлых оценок в личном кабинете, автодополнение адреса при поиске. +- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история). +- Чат поддержки на сайте, в том числе без входа в личный кабинет. +- Публичный лендинг «МЕРА». +- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта + страница истории версий. + +### Изменено + +- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более + раннего технического макета. + +### Исправлено + +- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе + комнатности не было варианта «Студия», из-за чего для студии подбирались + однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась. +- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор + автоматически расширяется (студии, срок объявлений, новостройки, радиус), + а над результатом показывается предупреждение о сниженной точности и о том, + какие параметры пришлось расширить. +- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части + адресов улица не распознавалась, и разделы оставались пустыми. +- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов. +- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки + на обложке, некорректные допущения о сроке экспозиции. diff --git a/tradein-mvp/VERSION b/tradein-mvp/VERSION new file mode 100644 index 00000000..7ec1d6db --- /dev/null +++ b/tradein-mvp/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile index 4958161c..24d65162 100644 --- a/tradein-mvp/backend/Dockerfile +++ b/tradein-mvp/backend/Dockerfile @@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages COPY --from=builder --chown=app:app /app/backend/app /app/app COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts +# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего +# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION). +# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста. +COPY --chown=app:app VERSION VERSION + +# Версия продукта + короткий git SHA + дата сборки — запечены как build-args +# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend). +# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам +# (VERSION-файл выше / "dev" / момент импорта модуля). +# +# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых +# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на +# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои +# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале +# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get + +# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот +# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже). +ARG APP_VERSION="" +ARG BUILD_SHA="" +ARG BUILD_DATE="" +ENV APP_VERSION=$APP_VERSION \ + BUILD_SHA=$BUILD_SHA \ + BUILD_DATE=$BUILD_DATE + USER app # HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые diff --git a/tradein-mvp/backend/app/api/v1/version.py b/tradein-mvp/backend/app/api/v1/version.py new file mode 100644 index 00000000..86729727 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/version.py @@ -0,0 +1,20 @@ +"""GET /api/v1/trade-in/version — build metadata (product version + short SHA + +build date), source `app/core/version.py`. + +Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) — это не +секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только +process env / уже загруженные при импорте константы (без похода в БД).""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA + +router = APIRouter() + + +@router.get("/version") +def get_version() -> dict[str, str]: + """{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}.""" + return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE} diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index e8c5fc04..0dd654ff 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset( "/api/v1/trade-in/support/anon/messages", "/api/v1/trade-in/support/anon/unread", "/api/v1/trade-in/support/anon/read", + # Версионирование (VERSION-файл + build-args, см. app/core/version.py): + # не секрет, читает только process env — быстрая справка для клиента/ + # поддержки/смоук-теста, не должна требовать сессию. + "/api/v1/trade-in/version", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/app/core/version.py b/tradein-mvp/backend/app/core/version.py new file mode 100644 index 00000000..44101a3a --- /dev/null +++ b/tradein-mvp/backend/app/core/version.py @@ -0,0 +1,83 @@ +"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`. + +`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env, +запечённые в образ через build-args в `backend/Dockerfile` +(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же +ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и +`date -u +%Y-%m-%dT%H:%M:%SZ`. + +Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env — +тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов, +см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта +модуля. Ничего здесь не должно падать при отсутствии env (потребитель — +и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`). + +Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда. +Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION` +в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и +`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля / +`@/lib/buildInfo` соответственно; не заводи третий. +""" + +from __future__ import annotations + +import datetime as dt +import os +from pathlib import Path + +_DEFAULT_VERSION = "0.0.0" +# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает +# и локальный layout (backend/app/core/version.py → ../../../VERSION == +# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/ +# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION). +_MAX_ANCESTORS = 6 + + +def _find_version_file() -> Path | None: + here = Path(__file__).resolve() + for ancestor in list(here.parents)[:_MAX_ANCESTORS]: + candidate = ancestor / "VERSION" + if candidate.is_file(): + return candidate + return None + + +def _read_version_file() -> str: + path = _find_version_file() + if path is None: + return _DEFAULT_VERSION + try: + text = path.read_text(encoding="utf-8").strip() + except OSError: + return _DEFAULT_VERSION + return text or _DEFAULT_VERSION + + +def _default_build_date() -> str: + return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# Читаются один раз при импорте модуля (совпадает с паттерном `settings = +# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем, +# перечитывать на каждый запрос незачем. +APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file() +BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev" +BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date() + + +def format_build_date_human(build_date: str = BUILD_DATE) -> str: + """ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF + колонтитул). Никогда не бросает исключение — при неразборчивой строке + возвращает её как есть (это футер отчёта, не API-контракт).""" + try: + parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return build_date + return parsed.strftime("%d.%m.%Y") + + +def product_version_line(product_name: str = "Мера") -> str: + """`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта + 2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF + колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`.""" + return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 7e99bbf5..347cad8c 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -34,6 +34,7 @@ from app.api.v1 import ( support, team, trade_in, + version, ) from app.core.auth_db import get_auth_engine from app.core.config import settings @@ -216,6 +217,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"]) app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) +app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"]) app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"]) app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 676d8f71..a89eadf6 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price from matplotlib.patches import Rectangle from app.core.config import settings +from app.core.version import product_version_line from app.schemas.trade_in import AggregatedEstimate, AnalogLot logger = logging.getLogger(__name__) @@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом) _BORDER = _LINE _BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline) -# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит -# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не -# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь -# HUD, а не версия PDF-модуля/API. -_REPORT_ENGINE_VERSION = "v2.0.6" - # Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/ # 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах. _FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions @@ -505,13 +500,31 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type: "ДАТА", report_date.strftime("%d.%m.%Y") ) + # Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение + # владельца продукта 2026-08-10, см. app/core/version.py::product_version_line. + # Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска + # реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера», + # одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под + # существующим masthead-рядом — не растёт по высоте ни при каком контенте + # (клипается по ширине, не переносится), top-margin (25mm) даёт под неё + # запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты. + version_html = ( + f'
' + f"{_html.escape(product_version_line())}
" + ) + return ( + f"
" f'
' + f'padding-bottom:6pt;margin-bottom:3pt;">' f"{mark_html}" f'{meta_html}' f"
" + f"{version_html}" + f"
" ) @@ -529,7 +542,11 @@ def _page_footer( строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ - хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта. + хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь + НЕ дублируется — единственное место вывода версии в PDF — running-header + (_page_header → product_version_line()); раньше рядом с wordmark висел + decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной + версией продукта — расходился с header на каждой странице, снесён. page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер на офер-странице), которого нет в веб-референсе (там нет пагинации). Не @@ -587,9 +604,6 @@ def _page_footer( font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2}; min-width:0;overflow-wrap:anywhere;"> {_html.escape(brand.name).upper()} - - {_REPORT_ENGINE_VERSION}
diff --git a/tradein-mvp/backend/tests/test_version_api.py b/tradein-mvp/backend/tests/test_version_api.py new file mode 100644 index 00000000..767e6b87 --- /dev/null +++ b/tradein-mvp/backend/tests/test_version_api.py @@ -0,0 +1,99 @@ +"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py + +app/api/v1/version.py. + +Isolated FastAPI app (no full app.main import, no DB) — same pattern as +tests/test_geocode_reverse_api.py: mount only the router under test. +""" + +from __future__ import annotations + +import importlib +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import version as version_module +from app.core import version as version_core + + +@pytest.fixture +def app() -> FastAPI: + application = FastAPI() + application.include_router(version_module.router, prefix="/api/v1/trade-in") + return application + + +# ── GET /api/v1/trade-in/version ───────────────────────────────────────────── + + +def test_version_endpoint_shape(app: FastAPI) -> None: + client = TestClient(app) + r = client.get("/api/v1/trade-in/version") + assert r.status_code == 200 + body = r.json() + assert set(body.keys()) == {"version", "sha", "built_at"} + assert isinstance(body["version"], str) and body["version"] + assert isinstance(body["sha"], str) and body["sha"] + assert isinstance(body["built_at"], str) and body["built_at"] + + +def test_version_endpoint_matches_core_constants(app: FastAPI) -> None: + client = TestClient(app) + body = client.get("/api/v1/trade-in/version").json() + assert body["version"] == version_core.APP_VERSION + assert body["sha"] == version_core.BUILD_SHA + assert body["built_at"] == version_core.BUILD_DATE + + +def test_version_path_is_public_no_auth_required() -> None: + """rbac_guard must let this path through without X-Authenticated-User / + session — see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call.""" + from app.core.rbac import _PUBLIC_PATHS + + assert "/api/v1/trade-in/version" in _PUBLIC_PATHS + + +# ── app/core/version.py — product_version_line / format_build_date_human ──── + + +def test_product_version_line_format() -> None: + line = version_core.product_version_line("Мера") + assert line.startswith("Мера v") + parts = line.split(" · ") + assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}" + + +def test_format_build_date_human_parses_iso_utc() -> None: + assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026" + + +def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None: + assert version_core.format_build_date_human("not-a-date") == "not-a-date" + + +# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ────── +# (local `uvicorn` run without a Docker build — see module docstring in +# app/core/version.py). Reloading the module re-executes its module-level +# env reads; nothing here may raise. + + +def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_VERSION", raising=False) + monkeypatch.delenv("BUILD_SHA", raising=False) + monkeypatch.delenv("BUILD_DATE", raising=False) + + reloaded = importlib.reload(version_core) + + assert reloaded.BUILD_SHA == "dev" + assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default + assert reloaded.BUILD_DATE.endswith("Z") + # format/product helpers must still work off the fallback values (no crash). + assert reloaded.product_version_line("Мера").startswith("Мера v") + + # Reload once more so any test running later in this process sees a module + # state consistent with whatever env pytest was actually invoked under. + importlib.reload(version_core) diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile index 9abbd35a..5def62b7 100644 --- a/tradein-mvp/frontend/Dockerfile +++ b/tradein-mvp/frontend/Dockerfile @@ -30,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_ENABLE_PREVIEW="" ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW +# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть +# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические +# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не +# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник +# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml, +# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для +# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже +# frontend-код, потребляющий эти env (Dockerfile сам файл не читает). +ARG NEXT_PUBLIC_APP_VERSION="" +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +ARG NEXT_PUBLIC_BUILD_SHA="" +ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA +ARG NEXT_PUBLIC_BUILD_DATE="" +ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE + +# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на +# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI +# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно +# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job +# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с +# остальным контекстом. Локальная сборка без этого шага CI просто не находит +# файл — readChangelog() уже умеет деградировать (пустая история), сам +# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже). COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build @@ -49,6 +72,13 @@ ENV NODE_ENV=production \ COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static +# /versions — Server Component, statically prerendered at `npm run build` +# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked +# into .next/standalone above. This is a defensive fallback ONLY, in case that +# page ever stops being static: glob (trailing `*`) makes it a no-op when the +# builder stage doesn't have the file either (local build without the CI +# pre-copy step, see builder stage comment above) — never fails the build. +COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./ USER node EXPOSE 3000 diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx index bf603195..c7d0543a 100644 --- a/tradein-mvp/frontend/src/app/v2/layout.tsx +++ b/tradein-mvp/frontend/src/app/v2/layout.tsx @@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google"; import { SupportButton } from "@/components/trade-in/v2/SupportButton"; import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext"; import { pageBg } from "@/components/trade-in/v2/tokens"; +import { VersionFooter } from "@/components/trade-in/VersionFooter"; // Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled // (no package.json change). Cyrillic + latin so RU labels render correctly. @@ -53,6 +54,15 @@ export default function TradeInV2Layout({ products without the МЕРА brand that don't need a support link. */} + {/* Real build-version indicator (task: показать реальную версию + продукта «Мера» в вебе). Deliberately OUTSIDE SupportChatProvider — + it needs no chat context — but still scoped to this /v2 layout for + the same reason SupportButton is: other basePath routes + (/scrapers/**, /sale-share) are unrelated products without the + МЕРА brand. Portals to document.body itself (see VersionFooter.tsx + docstring), so its position in this tree only matters for mount + order, not DOM placement. */} + ); } diff --git a/tradein-mvp/frontend/src/app/versions/page.tsx b/tradein-mvp/frontend/src/app/versions/page.tsx new file mode 100644 index 00000000..7c82a2ab --- /dev/null +++ b/tradein-mvp/frontend/src/app/versions/page.tsx @@ -0,0 +1,120 @@ +// /versions (→ `/trade-in/versions` behind basePath) — «История версий». +// +// Server Component, deliberately NOT "use client": `readChangelog()` reads +// `tradein-mvp/CHANGELOG.md` off disk via `fs.readFileSync` at build/render +// time and gets statically embedded — no client-side fetch, no network hop +// (see src/lib/changelog.ts for the exact read/parse contract + a known +// build-context gap, flagged there). +// +// Auth: this route has NO guard of its own — it lives inside the same +// app-router segment as every other closed МЕРА page (history/, cache/, +// team/), so `app/layout.tsx`'s `` already gates it exactly +// like the rest of the product. No new RBAC path was added; whatever the +// backend `auth/roles.yaml` wildcard already allows for `/trade-in/**` +// covers this page too. +import type { Metadata } from "next"; +import Link from "next/link"; + +import "@/components/trade-in/trade-in.css"; +import { APP_VERSION, formatRuDate } from "@/lib/buildInfo"; +import { readChangelog } from "@/lib/changelog"; + +export const metadata: Metadata = { + title: "История версий — МЕРА", +}; + +export default function VersionsPage() { + const entries = readChangelog(); + + return ( +
+

+ ← К оценке +

+ +

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

+

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

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

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

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

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

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

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

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

+ {section.title} +

+
    + {section.items.map((item, i) => ( +
  • + {item} +
  • + ))} +
+
+ )) + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx new file mode 100644 index 00000000..426e46df --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx @@ -0,0 +1,80 @@ +"use client"; + +// VersionFooter — small build-version indicator for the МЕРА product, +// showing the REAL deployed version. This is the SINGLE place on /trade-in/v2 +// that renders a version number — `v2/TopNav.tsx` and `v2/Footer.tsx` used to +// each carry their own hardcoded "v2.0.6" literal (`./ui-config`'s `version`) +// next to the МЕРА wordmark; both were removed (three independent "versions" +// on one screen, see PR review) — the wordmark stays in both places, just +// without a number attached. Values here come from build-time +// `NEXT_PUBLIC_*` env vars via `@/lib/buildInfo` — no runtime API call, no +// useEffect fetch. +// +// Mounted in `app/v2/layout.tsx` (not `app/v2/page.tsx` — that file is +// off-limits for this change), right next to ``. +// +// Portaled to document.body — same reasoning/pattern as SupportButton.tsx: +// /v2 renders its HUD inside a fixed-size "artboard" that gets +// `transform: scale(...)` on narrow viewports (app/v2/page.tsx), and a +// `position: fixed` descendant of a transformed ancestor is positioned +// relative to THAT ancestor, not the real viewport corner — portaling +// sidesteps that entirely, exactly like the support button already does. +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import { tokens } from "@/components/trade-in/v2/tokens"; +import { formatVersionLabel } from "@/lib/buildInfo"; + +const styles = ` +.version-footer{opacity:.72;transition:opacity .15s;} +.version-footer:hover{opacity:1;} +.version-footer a{color:${tokens.muted2};text-decoration:underline;text-underline-offset:2px;} +.version-footer a:hover{color:${tokens.ink};} +@media (max-width: 480px){ + .version-footer{left:10px !important;bottom:10px !important;padding:3px 7px !important;font-size:9px !important;gap:6px !important;} +} +`; + +export function VersionFooter() { + // Portal-mount guard (SSR-safe): `document` only exists after mount + // (mirrors SupportButton.tsx / MapPicker.tsx). + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!mounted) return null; + + return createPortal( + <> + +
+ {formatVersionLabel()} + История версий +
+ , + document.body, + ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index 9dab743a..3b04d0b7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -1,10 +1,13 @@ // Report footer for the /trade-in/v2 "МЕРА Оценка" design port. // Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439): // report id / date / valid-until on the left, a decorative centre line, and the -// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`. +// МЕРА wordmark on the right. Static markup, id/date/validUntil via `data`. +// The trailing "v2.0.6" badge that used to sit next to the wordmark was a +// hardcoded literal (./ui-config `version`), independent of the real deployed +// build — removed. The real version is shown once, by `` +// (see app/v2/layout.tsx), not duplicated here. import { tokens } from "./tokens"; -import { version } from "./ui-config"; import type { Report } from "./types"; interface FooterProps { @@ -122,16 +125,6 @@ export function Footer({ data, hasEstimate }: FooterProps) { > МЕРА
- - {version} - ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 9bf5a19d..70674679 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -2,10 +2,13 @@ // Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port. // 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 + 5 nav tabs (active underline/triangle) + user menu. // Tabs change only local UI state via onNavigate; the user dropdown owns its -// own useState. No data fetching — labels/version come from ./ui-config, the user +// own useState. No data fetching — labels come from ./ui-config, the user // identity is fed in from the page (real useMe), colours from tokens. +// The build-version badge that used to sit next to the logo (hardcoded +// "v2.0.6") was removed — the real deployed version is shown once, by +// `` (app/v2/layout.tsx), not duplicated here. import { useState } from "react"; import type { CSSProperties } from "react"; @@ -13,7 +16,7 @@ import type { CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; import { tokens } from "./tokens"; -import { navLabels, version } from "./ui-config"; +import { navLabels } from "./ui-config"; import { useSupportChat } from "./SupportChatContext"; // Real logged-in user identity, derived by the page from useMe() @@ -70,15 +73,29 @@ const menuItemStyle: CSSProperties = { tokens.muted), что и остальные иконки этого дропдауна. */ function UsersIcon() { return ( -
-
- {version} -
{/* Nav tabs */} @@ -412,7 +417,13 @@ export default function TopNav({ aria-disabled="true" title="Раздел «Профиль» скоро появится" > -
- -