From 5ff06d25b46c70f82fc9370d814417da112ab726 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 21:48:06 +0300 Subject: [PATCH 01/98] =?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'' + f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}" + if estimate.retain_until is not None + else "" + ) + # Короткий адрес (для cover): берём первую часть до запятой full_address = input_snapshot.get("address", "—") address_short = full_address.split(",")[0:3] @@ -1146,6 +1159,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s {_mono(today.strftime("%d.%m.%Y"))} Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))} + {retain_until_row} Адрес{address} Год постройки {year_label} 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 48664dfe0e7929be804260b4775b6c915cc0d1bb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 22:49:09 +0300 Subject: [PATCH 02/98] =?UTF-8?q?fix(tradein/payments):=20pre-flight=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=20=D0=BB=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B0=D0=BD=D0=BE=D0=BC=D0=B0=D0=BB=D0=B8=D1=8E?= =?UTF-8?q?,=20=D0=BD=D0=B5=20=D1=88=D1=82=D0=B0=D1=82=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20(re?= =?UTF-8?q?view=20PR=20#2754)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review MEDIUM: предполётная проверка purge_expired_trade_in_data считала по базовому предикату без retain_until — здоровая оплаченная строка (retain_until проставлен, платёж есть) через сутки после продажи тоже попадала под счётчик, и джоба аварийно останавливалась на первой же честной продаже навсегда (вместе с ней — и 180-дневное удаление лидов, вызываемое из той же функции после этой проверки). - _PREFLIGHT_PAID_CANDIDATES_SQL: добавлен терм `retain_until IS NULL` — теперь считает только реальную аномалию (retain_until не проставлен, а платёж есть), а не штатное состояние. Докстринги функции/модуля поправлены под фактическое поведение. - Тест на неверный инвариант (`"retain_until" not in sql`) заменён на позитивный (`"retain_until IS NULL" in sql`) + добавлены live-DB тесты на оба случая из ревью (здоровая оплаченная строка не поднимает тревогу, джоба не блокируется). - privacy/page.tsx: константа "12 месяцев" вынесена в content.ts (PAID_REPORT_RETENTION_MONTHS) вместо литерала + расходящегося комментария; добавлен сверяющий тест (test_paid_retention_text_consistency.py) по образцу _CONSENT_TEXT_SNAPSHOT. Смягчена формулировка про автоматическое удаление — задача на проде выключена и ни разу не запускалась, текст теперь описывает установленный порядок, а не наблюдаемый факт. - Все 10 висячих ссылок на untracked `mera-pr-d-spec.md` (7 файлов) заменены на краткое изложение сути в комментарии + ссылку на PR #2754. --- tradein-mvp/backend/app/api/v1/trade_in.py | 2 +- tradein-mvp/backend/app/core/config.py | 19 +- .../app/tasks/purge_expired_trade_in_data.py | 91 +++++--- .../234_trade_in_estimates_retain_until.sql | 5 +- .../backend/tests/test_estimate_idor.py | 2 +- .../test_paid_retention_text_consistency.py | 103 +++++++++ .../tests/test_purge_expired_trade_in_data.py | 206 ++++++++++++++++-- .../frontend/src/app/mera-public/content.ts | 19 ++ .../src/app/mera-public/privacy/page.tsx | 29 ++- 9 files changed, 401 insertions(+), 75 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_paid_retention_text_consistency.py diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 05950b0c..3bc7c08d 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -58,7 +58,7 @@ router = APIRouter() # неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false # в SQL — для всех существующих строк (retain_until IS NULL) поведение не # меняется вообще. Не копировать это выражение по месту — только через -# константу/хелпер ниже. См. `mera-pr-d-spec.md` §1.3 в корне репо. +# константу/хелпер ниже. Payments retention, PR #2754. ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())" diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 9a9b4ebf..eb70c787 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -836,15 +836,20 @@ class Settings(BaseSettings): # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. trade_in_lead_retention_days: int = 180 - # ── PR-D1: платный отчёт живёт год (retain_until, migration 234) ──────── + # ── Платный отчёт живёт год (retain_until, migration 234, PR #2754) ───── # 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. + # расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см. + # migration 234 докстринг. Отдельная колонка, а не подъём expires_at: + # expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков + # для всех строк, поднять его до года = соврать в документе клиента про + # свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов. + # Единственный источник числа «12 месяцев» на фронте — + # `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты, + # экран после оплаты и SQL продления retain_until при оплате (платёжный + # код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе + # классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране + # «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. trade_in_paid_retention_days: int = 365 # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним 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 5a96989c..c48e8dba 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 @@ -55,16 +55,20 @@ BATCHING (не единый DELETE по всей таблице): 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. +Payments retention (PR #2754): the `created_by IS NULL` population above is EXACTLY + the future paying-customer population -- the owner sells this report to + individuals for money, and a paid row must outlive the 24h `expires_at` link TTL + (a separate column, `retain_until`, set by the -- separate, not-yet-existing -- + payment fulfillment code to now() + settings.trade_in_paid_retention_days, NOT + a change to `expires_at` itself). Two independent safeguards were added to + `_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS payments) + plus a pre-flight count in `purge_expired_trade_in_data` that refuses to run at + all if it finds an ANOMALOUS paid candidate -- see the SQL constants and + `_preflight_paid_candidates` below for the mechanics (deep-review finding + 2026-08-06 MEDIUM on PR #2754: the pre-flight predicate itself must ALSO carry + `retain_until IS NULL`, otherwise a perfectly healthy paid row trips it and + wedges the job permanently -- see that function's docstring). No payment code + lives in this file. """ from __future__ import annotations @@ -86,9 +90,14 @@ logger = logging.getLogger(__name__) _DEFAULT_MAX_BATCHES = 20 # -# PR-D1 (2026-08-06): два независимые предохранителя добавлены к тому же -# предикату, ПЕРЕД тем как платёжный код появился в проекте (мина уже была -# заряжена, см. `mera-pr-d-spec.md` §1 в корне репо): +# Payments retention (2026-08-06, PR #2754): два независимых предохранителя +# добавлены к тому же предикату ПЕРЕД тем, как платёжный код появился в +# проекте (мина уже была заряжена: без них джоба удаляла бы будущих платящих +# клиентов). Отдельная колонка retain_until (не подъём expires_at) — потому +# что expires_at глобальный TTL расчёта на ВСЕ строки (включая неоплаченные) +# и печатается в PDF/UI как «актуальность расчёта»; поднять его до года +# означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в +# документе клиента про срок актуальности цифры: # 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная # строка (retain_until IS NOT NULL, migration 234) не удаляется джобой # В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем @@ -117,17 +126,25 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text( """ ) -# 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). +# Pre-flight (см. _preflight_paid_candidates). deep-review finding 2026-08-06 +# MEDIUM (PR #2754): первая редакция считала по БАЗОВОМУ предикату БЕЗ +# retain_until вообще -- а это ловит и штатно-здоровые оплаченные строки +# (retain_until проставлен, есть payments) точно так же, как настоящую +# аномалию (retain_until НЕ проставлен, но payments есть) -- джоба вставала +# на первой же честной продаже и больше никогда не запускалась (вместе с ней +# вставало и удаление лидов, вызываемое из той же функции ПОСЛЕ этой +# проверки -- 180-дневный purge по 152-ФЗ тоже переставал бы работать). +# Правильная форма: базовый предикат AND "новый предохранитель НЕ сработал +# бы" (retain_until IS NULL) AND "признак аномалии" (payments всё же есть). +# Здоровая оплаченная строка (retain_until IS NOT NULL) исключается ЭТИМ +# термом -- она и так под DELETE не попадает (см. safeguard 1 выше), тревогу +# поднимать не должна. _PREFLIGHT_PAID_CANDIDATES_SQL = text( """ SELECT count(*) FROM trade_in_estimates e WHERE e.expires_at < NOW() AND e.created_by IS NULL + AND e.retain_until IS NULL AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id) """ ) @@ -181,14 +198,20 @@ def _drain_expired( def _preflight_paid_candidates(db: Session) -> int: - """PR-D1 safety gate: count purge-candidates (base predicate) that have a payments row. + """Safety gate: count ANOMALOUS purge-candidates -- base predicate, retain_until + IS NULL (safeguard 1 did NOT protect the row), AND a payments row exists anyway. - Runs BEFORE any DELETE batch. A non-zero result means 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. + Runs BEFORE any DELETE batch. A non-zero result means fulfillment failed to set + `retain_until` on a row money actually touched (bug/race/manual INSERT) -- this + run must not delete anything; see `purge_expired_trade_in_data` below, which + aborts before the first batch when this returns non-zero. + + MUST include `retain_until IS NULL` (deep-review finding 2026-08-06 MEDIUM, PR + #2754): a healthy paid row (retain_until set, has a payments row) is the EXPECTED + steady state one day after every sale -- without this term it counts as a "paid + candidate" too, so the very first successful sale permanently wedges this job + (mark_failed, zero deletions, forever -- and since leads purge runs from the same + function AFTER this check, the unrelated 180-day lead retention would also stop). """ return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one() @@ -207,11 +230,15 @@ def purge_expired_trade_in_data( 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. + Payments retention pre-flight (see `_preflight_paid_candidates`): if any + purge-candidate estimate has `retain_until IS NULL` AND a `payments` row (the + ANOMALY -- fulfillment failed to set retain_until on a row money touched), the + run aborts BEFORE the first DELETE batch (estimates OR leads) -- zero rows + deleted, `mark_failed` records why. Healthy paid rows (retain_until set) do NOT + trip this -- they never matched the check to begin with. This is deliberately + checked outside the `try` below so it can never be caught and silently + re-reported as a generic mid-run failure -- it is a distinct, actionable + pre-condition failure. """ batch_size = batch_size or settings.trade_in_purge_batch_size max_batches = max_batches or _DEFAULT_MAX_BATCHES @@ -221,7 +248,7 @@ def purge_expired_trade_in_data( 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) -- " + "row(s) have retain_until IS NULL but a matching payments row -- " "refusing to run, zero rows deleted" ) logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error) 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 index 2d2e3d4f..912eb915 100644 --- 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 @@ -1,6 +1,7 @@ -- 234_trade_in_estimates_retain_until.sql --- PR-D1 «Ретеншен: оплаченное живёт год, purge его не трогает» — см. --- `mera-pr-d-spec.md` §1 в корне репо (обоснования там, здесь только SQL). +-- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge +-- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен +-- жить год на нашей стороне, а не 24ч (см. WHY ниже). -- Номер сверен и по `forgejo/main`, и по всем открытым PR-веткам на момент -- написания (последняя занятая — 233_payments.sql) — см. урок в шапке того -- же файла про то, как коллизия 228/229/231/232 обнаруживается поздно. diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 1dc6aaf9..7cb0e735 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -605,7 +605,7 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) assert resp.status_code == 404 -# ── PR-D1: retention gate unification (retain_until, mera-pr-d-spec.md §1.3) ── +# ── Payments retention: retention gate unification (retain_until, PR #2754) ── def test_estimate_readable_sql_uses_disjunction() -> None: diff --git a/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py new file mode 100644 index 00000000..434d419f --- /dev/null +++ b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py @@ -0,0 +1,103 @@ +"""Payments retention (PR #2754) — "12 месяцев" text sync guard. + +WHY: + mera-public/content.ts declares itself as the ONE place product promises + live (docstring at the top of that file: "ни одного утверждения, которого + не делает код"). The public retention promise ("оплаченный отчёт хранится + N месяцев") has THREE places it could quietly drift: the backend setting + (`settings.trade_in_paid_retention_days`), the frontend constant + (`PAID_REPORT_RETENTION_MONTHS` in content.ts), and any page that renders + it (today: privacy/page.tsx). Deep-review finding 2026-08-06 MEDIUM on + PR #2754 caught exactly this: a comment claimed the number "reads from the + same setting" while the page actually hardcoded a `12 месяцев` literal -- + a comment cannot fail CI, same lesson as + test_consent_text_frontend_sync.py's _CONSENT_TEXT_SNAPSHOT guard (which + this file mirrors). + +WHAT: + 1. privacy/page.tsx imports PAID_REPORT_RETENTION_MONTHS from content.ts + and does NOT hardcode a "N месяцев" literal of its own. + 2. The frontend months constant and the backend days setting stay within + a sane calendar tolerance of each other (28-31 days per month) -- this + does NOT enforce byte-identity (days and months are different units by + design, see content.ts docstring), only that nobody silently changes + one without the other drifting out of "still honestly ~12 months". +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_FRONTEND_CONTENT = ( + Path(__file__).resolve().parents[2] / "frontend" / "src" / "app" / "mera-public" / "content.ts" +) +_FRONTEND_PRIVACY_PAGE = ( + Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "app" + / "mera-public" + / "privacy" + / "page.tsx" +) + +_MONTHS_CONST_RE = re.compile(r"PAID_REPORT_RETENTION_MONTHS\s*=\s*(\d+)\s*;") +_LITERAL_MONTHS_RE = re.compile(r'"?\d+ месяцев"?') + + +def _extract_months_constant(content_ts_source: str) -> int: + match = _MONTHS_CONST_RE.search(content_ts_source) + assert match is not None, ( + "PAID_REPORT_RETENTION_MONTHS not found in content.ts -- " + "constant renamed/removed without updating this test" + ) + return int(match.group(1)) + + +def test_frontend_files_exist() -> None: + assert _FRONTEND_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_CONTENT}" + assert _FRONTEND_PRIVACY_PAGE.is_file(), f"missing frontend file: {_FRONTEND_PRIVACY_PAGE}" + + +def test_privacy_page_imports_retention_constant_not_hardcoded() -> None: + """The whole point: FAILS if privacy/page.tsx stops importing the shared + constant and goes back to a hardcoded '12 месяцев' literal (exactly the + drift the deep-review finding caught -- comment said 'reads from content.ts', + code said otherwise).""" + src = _FRONTEND_PRIVACY_PAGE.read_text(encoding="utf-8") + assert "PAID_REPORT_RETENTION_MONTHS" in src, ( + "privacy/page.tsx no longer references PAID_REPORT_RETENTION_MONTHS -- " + "the retention promise must be rendered from the shared content.ts " + "constant, not written out again by hand" + ) + assert re.search(r'from\s+"\.\./content"', src), ( + "privacy/page.tsx must import from '../content' (content.ts), where " + "PAID_REPORT_RETENTION_MONTHS is declared" + ) + literal_hits = _LITERAL_MONTHS_RE.findall(src) + assert not literal_hits, ( + "privacy/page.tsx contains a hardcoded 'N месяцев' literal -- render the " + "PAID_REPORT_RETENTION_MONTHS constant instead: " + f"{literal_hits!r}" + ) + + +def test_backend_days_setting_matches_frontend_months_within_calendar_tolerance() -> None: + """Not byte-identity (days vs months are different units, deliberately -- + see content.ts docstring on PAID_REPORT_RETENTION_MONTHS): just a sanity + bound that `trade_in_paid_retention_days` still honestly rounds to the + number of months the public page promises (28-31 days/month, generous).""" + from app.core.config import settings + + days = settings.trade_in_paid_retention_days + months = _extract_months_constant(_FRONTEND_CONTENT.read_text(encoding="utf-8")) + + assert 28 * months <= days <= 31 * months, ( + f"settings.trade_in_paid_retention_days={days} no longer honestly rounds to " + f"content.ts PAID_REPORT_RETENTION_MONTHS={months} -- update both together " + "(and the offer text, when it exists) so the public promise stays true" + ) diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index caa1b207..a49f1a96 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,20 +7,22 @@ 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. + - Payments retention (PR #2754): retain_until IS NULL + NOT EXISTS(payments) + safeguards on the estimates DELETE, plus a pre-flight that refuses to run + at all if it finds an ANOMALOUS paid purge-candidate (retain_until unset + despite a payments row) -- a healthy paid row (retain_until set) must NOT + trip it, see test_preflight_ignores_healthy_paid_row below. Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched runs_mod.mark_done/mark_failed). -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). +Payments retention note on _FakeDB: purge_expired_trade_in_data now issues ONE +extra db.execute() call BEFORE any DELETE batch — the pre-flight paid- +candidates count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that +statement by identity and answers it from `preflight_count` (default 0 == +"no anomalous candidates, proceed exactly as before this PR"). Every +pre-existing test's `db.executed` index shifted by +1 to account for this; +`db.commits` is unaffected (the pre-flight is a read, never committed). """ from __future__ import annotations @@ -169,7 +171,7 @@ 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 ──────── +# ── Payments retention (PR #2754): two independent purge safeguards ───────── def test_estimates_sql_excludes_retain_until_not_null() -> None: @@ -190,15 +192,20 @@ def test_estimates_sql_has_not_exists_payments_safeguard() -> None: 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).""" +def test_preflight_sql_requires_retain_until_is_null() -> None: + """Deep-review finding 2026-08-06 MEDIUM (PR #2754): the pre-flight predicate + MUST carry `retain_until IS NULL` -- without it, a perfectly healthy paid row + (retain_until set, has a payments row -- the ORDINARY steady state one day + after every sale) trips the alarm exactly as hard as the real anomaly + (retain_until unset despite a payments row existing), permanently wedging + the job on the very first successful sale (and, since leads purge runs from + the same function AFTER this check, silently stopping 180-day 152-ФЗ lead + retention too). See test_real_preflight_ignores_healthy_paid_row below for + the behavioural proof against a real DB.""" sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text assert "expires_at < NOW()" in sql assert "created_by IS NULL" in sql - assert "retain_until" not in sql + assert "retain_until IS NULL" in sql assert "EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)" in sql assert not re.search(r":\w+::", sql) @@ -230,10 +237,10 @@ def test_preflight_zero_candidates_proceeds_as_before(monkeypatch: pytest.Monkey 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 — оставить - дословно').""" + """Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by payments + retention (PR #2754) — leads have their own retention deadline (migration + 231, no created_by/B2B split, no payments concept) and are explicitly out + of scope for the payments-retention safeguards.""" expected = ( "\n DELETE FROM trade_in_leads\n WHERE id IN (\n" " SELECT id FROM trade_in_leads\n" @@ -443,3 +450,160 @@ def test_real_purge_deletes_only_anonymous_expired_estimates() -> None: ) db.commit() db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754, reproduced exactly + against a real DB: a HEALTHY paid row (retain_until set, a payments row + exists) is the ordinary steady state one day after every sale and must NOT + raise the pre-flight count; a row where fulfillment failed to set + retain_until despite a payments row existing is the real ANOMALY and must. + Baseline-delta assertions (not absolute counts) so this is safe to run + against a dev DB that may already contain unrelated rows.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + anomaly_id = uuid4() + healthy_order = f"pytest-healthy-{uuid4().hex[:12]}" + anomaly_order = f"pytest-anomaly-{uuid4().hex[:12]}" + try: + baseline = task_mod._preflight_paid_candidates(db) + + # Healthy: retain_until set (paid, safeguard 1 already protects it) + + # a payments row -- exactly what every successful sale looks like a day + # later. Must NOT move the pre-flight count. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test здоровая оплаченная', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + assert ( + task_mod._preflight_paid_candidates(db) == baseline + ), "healthy paid row (retain_until set) must NOT raise the pre-flight count" + + # Anomaly: retain_until NULL despite a payments row existing -- exactly + # the case the two DELETE safeguards exist for. Must raise by exactly one. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test настоящая аномалия', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NULL)" + ), + {"id": str(anomaly_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": anomaly_order, "id": str(anomaly_id)}, + ) + db.commit() + assert task_mod._preflight_paid_candidates(db) == baseline + 1, ( + "anomaly row (retain_until unset, payments row exists) must raise " + "the pre-flight count by exactly one" + ) + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = ANY(CAST(:orders AS text[]))"), + {"orders": [healthy_order, anomaly_order]}, + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(healthy_id), str(anomaly_id)]}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_purge_not_wedged_by_healthy_paid_row() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754: before the fix, a + healthy paid row anywhere in the table (retain_until set, has a payments + row) permanently wedged the job -- the very first successful sale would + have made every subsequent scheduled run abort in mark_failed with zero + deletions FOREVER, silently taking 180-day leads purge (152-ФЗ) down with + it (leads purge runs from the same function AFTER the pre-flight check). + This proves a real end-to-end run completes normally (mark_done) in the + presence of such a row.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + healthy_order = f"pytest-wedge-{uuid4().hex[:12]}" + try: + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test не блокирует джобу', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + + # Must complete normally -- no RuntimeError, no mark_failed short-circuit + # (would raise before reaching this line if the bug were still present). + result = task_mod.purge_expired_trade_in_data( + db, run_id=999999998, batch_size=100, max_batches=1 + ) + assert set(result) == {"estimates_deleted", "leads_deleted"}, ( + "leads purge must also have run -- it is NOT reachable when the " + "pre-flight wrongly aborts first" + ) + + still_there = db.execute( + _t("SELECT id FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ).fetchone() + assert still_there is not None, "healthy paid row must survive the run untouched" + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = :order_id"), {"order_id": healthy_order} + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ) + db.commit() + db.close() diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 3e521b68..66772877 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -92,6 +92,25 @@ export const LEGAL_ENTITY: { /** Внутренний маршрут страницы про обработку персональных данных. */ export const PRIVACY_PATH = "/mera-public/privacy"; +/** + * Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта + * после оплаты (`trade_in_estimates.retain_until`, migration 234) — НЕ срок + * действия самого расчёта (тот отдельный, `expires_at`, часы). + * + * ЕДИНСТВЕННОЕ место, где это число хардкодится на фронте — любой другой + * текст (оферта, экран после оплаты) обязан импортировать эту константу, а + * не писать «12 месяцев» заново (см. `test_paid_retention_text_consistency.py` + * — проверяет, что privacy-страница действительно использует эту константу, + * а не литерал). + * + * Источник истины на бэкенде — `settings.trade_in_paid_retention_days = 365` + * (`app/core/config.py`). 365 дней округляется до «12 месяцев» для + * человекочитаемого текста (12×30=360..12×31=372 — 365 попадает в диапазон); + * если бэкендовое число когда-нибудь изменится так, что «12» перестанет быть + * честным округлением — обновить оба места руками, тест это тоже проверяет. + */ +export const PAID_REPORT_RETENTION_MONTHS = 12; + // --------------------------------------------------------------------------- // География // --------------------------------------------------------------------------- diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx index 86fd3cec..0cdd50cf 100644 --- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { LEGAL_ENTITY, + PAID_REPORT_RETENTION_MONTHS, PUBLIC_ESTIMATE_ENABLED, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL, @@ -42,11 +43,16 @@ import { safeUrl } from "@/lib/safeUrl"; * стирается (см. докстринг `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 - * того же дока, а не «потом». + * - Срок хранения оплаченного отчёта (PR #2754, `retain_until` / + * `trade_in_paid_retention_days` в backend `config.py`): число месяцев + * ниже — константа `PAID_REPORT_RETENTION_MONTHS` из `../content.ts`, + * НЕ литерал здесь (см. докстринг константы — она единственный источник + * этого числа на фронте; `test_paid_retention_text_consistency.py` + * проверяет, что эта страница действительно её импортирует). Платёжного + * кода в этом PR нет — срок описан на будущее, вместе с честной правкой + * ниже про то, что удаление сегодня описывает установленный ПОРЯДОК, а + * не наблюдаемый на проде автоматический прогон (задача засеяна + * выключенной). * * Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока * расчёт выключен, адрес действительно не покидает браузер; после включения это @@ -149,12 +155,13 @@ export default function MeraPublicPrivacyPage() { Самостоятельной кнопки «удалить мои данные» в интерфейсе пока нет, но механизм удаления в сервисе есть: обращение в поддержку об удалении мы разбираем вручную и физически стираем телефон, адрес и расчёт из базы, - а не просто помечаем запись. Помимо запроса, у данных есть собственный - срок хранения, по истечении которого они удаляются без обращения с - вашей стороны. Если результат расчёта оплачен, ссылка на отчёт и - связанные с ним данные хранятся на нашей стороне 12 месяцев с даты - оплаты, после чего удаляются точно так же — на файл, который вы - скачали себе, это не влияет: мы его не отзываем, не изменяем и не + а не просто помечаем запись. Помимо запроса, для каждого типа данных + установлен срок хранения, по истечении которого они подлежат + удалению. Если результат расчёта оплачен, ссылка на отчёт и + связанные с ним данные хранятся на нашей стороне{" "} + {PAID_REPORT_RETENTION_MONTHS} месяцев с даты оплаты, а затем + подлежат удалению так же, как и остальные данные — на файл, который + вы скачали себе, это не влияет: мы его не отзываем, не изменяем и не имеем к нему доступа.

From e6591a450a829f94924bf85a3fa6c541ff10a9c8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 15:31:01 +0300 Subject: [PATCH 03/98] =?UTF-8?q?fix(tradein/payments):=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20234=20=E2=86=92=20240=20?= =?UTF-8?q?=E2=80=94=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=20=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=20=D0=BD=D0=B0=20?= =?UTF-8?q?main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main уехал вперёд за сутки: 234 занял 234_scrape_runs_ban_kind_unknown.sql (0de22f4b), максимум на main сейчас 239 (235-237 — дыры). max+1=240 безопаснее дыр; ни один открытый PR номер 235-240 не занимает (сверено по forgejo/main и всем открытым веткам). Переименован файл + обновлены все 7 упоминаний "migration 234" (_manifest_applied.txt, config.py, schemas/trade_in.py, purge_expired_trade_in_data.py, test_estimate_idor.py, content.ts, types/trade-in.ts) — правки текстовые, ни один тест не читает миграцию по имени файла. --- tradein-mvp/backend/app/core/config.py | 4 ++-- tradein-mvp/backend/app/schemas/trade_in.py | 2 +- .../backend/app/tasks/purge_expired_trade_in_data.py | 2 +- ...l.sql => 240_trade_in_estimates_retain_until.sql} | 12 ++++++++---- tradein-mvp/backend/data/sql/_manifest_applied.txt | 2 +- tradein-mvp/backend/tests/test_estimate_idor.py | 2 +- tradein-mvp/frontend/src/app/mera-public/content.ts | 2 +- tradein-mvp/frontend/src/types/trade-in.ts | 2 +- 8 files changed, 16 insertions(+), 12 deletions(-) rename tradein-mvp/backend/data/sql/{234_trade_in_estimates_retain_until.sql => 240_trade_in_estimates_retain_until.sql} (87%) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index eb70c787..b0f5b791 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -836,11 +836,11 @@ class Settings(BaseSettings): # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. trade_in_lead_retention_days: int = 180 - # ── Платный отчёт живёт год (retain_until, migration 234, PR #2754) ───── + # ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ───── # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность # расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см. - # migration 234 докстринг. Отдельная колонка, а не подъём expires_at: + # migration 240 докстринг. Отдельная колонка, а не подъём expires_at: # expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков # для всех строк, поднять его до года = соврать в документе клиента про # свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов. diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index c7620ece..4f3d4996 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -197,7 +197,7 @@ class AggregatedEstimate(BaseModel): actual_deals: list[AnalogLot] # реальные продажи last 12 mo expires_at: datetime # PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности - # расчёта — тот остаётся expires_at (не путать, см. migration 234). + # расчёта — тот остаётся expires_at (не путать, см. migration 240). # NULL = неоплачено (весь текущий трафик, B2B pilots включительно). retain_until: datetime | 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 c48e8dba..4dcda362 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 @@ -99,7 +99,7 @@ _DEFAULT_MAX_BATCHES = 20 # означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в # документе клиента про срок актуальности цифры: # 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная -# строка (retain_until IS NOT NULL, migration 234) не удаляется джобой +# строка (retain_until IS NOT NULL, migration 240) не удаляется джобой # В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем # через год после первой продажи. `retain_until` ставится сервисным # кодом платёжного контура (ещё не существует в этом PR) на now() + diff --git a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql similarity index 87% rename from tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql rename to tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql index 912eb915..93693455 100644 --- a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql +++ b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql @@ -1,10 +1,14 @@ --- 234_trade_in_estimates_retain_until.sql +-- 240_trade_in_estimates_retain_until.sql -- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge -- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен -- жить год на нашей стороне, а не 24ч (см. WHY ниже). --- Номер сверен и по `forgejo/main`, и по всем открытым PR-веткам на момент --- написания (последняя занятая — 233_payments.sql) — см. урок в шапке того --- же файла про то, как коллизия 228/229/231/232 обнаруживается поздно. +-- Номер сверен по `forgejo/main` и всем открытым PR-веткам ДВАЖДЫ: сначала +-- как 234 (последняя занятая на момент ветвления была 233_payments.sql), но +-- main уехал вперёд и 234 занял `234_scrape_runs_ban_kind_unknown.sql` +-- (коммит 0de22f4b) — переименовано в 240 (main max на момент повторной +-- сверки — 239, с дырами 235-237; max+1 безопаснее дыр). Урок пятый за +-- сутки: сверять номер нужно не только перед первым коммитом, а прямо перед +-- пушем/мержем — main не стоит на месте. -- -- ── WHY ────────────────────────────────────────────────────────────────────── -- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 27b06836..72981f2c 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -231,4 +231,4 @@ # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. 233_payments.sql -234_trade_in_estimates_retain_until.sql +240_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 7cb0e735..a9ed8d31 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -54,7 +54,7 @@ def trade_in_app() -> FastAPI: 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 + retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every row that existed before that migration; explicit param lets retention-gate tests (see test_estimate_retention_gate.py) construct a paid row. """ diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 66772877..5e5c2937 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -94,7 +94,7 @@ export const PRIVACY_PATH = "/mera-public/privacy"; /** * Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта - * после оплаты (`trade_in_estimates.retain_until`, migration 234) — НЕ срок + * после оплаты (`trade_in_estimates.retain_until`, migration 240) — НЕ срок * действия самого расчёта (тот отдельный, `expires_at`, часы). * * ЕДИНСТВЕННОЕ место, где это число хардкодится на фронте — любой другой diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index ce3282bd..94f1dea2 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -172,7 +172,7 @@ export interface AggregatedEstimate { analogs: AnalogLot[]; // top 5-10 actual_deals: AnalogLot[]; // last 12 mo expires_at: string; // ISO datetime - // PR-D1: срок жизни ссылки/строки (оплаченный доступ, backend migration 234), + // PR-D1: срок жизни ссылки/строки (оплаченный доступ, backend migration 240), // НЕ путать с expires_at (актуальность расчёта). null = неоплачено (текущий // трафик целиком). retain_until?: string | null; // ISO datetime From 5ce95a28a864a2327df83d5f985636c09fbf365b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 15:59:28 +0300 Subject: [PATCH 04/98] =?UTF-8?q?fix(tradein/payments):=20SET=20LOCAL=20lo?= =?UTF-8?q?ck=5Ftimeout=20=D0=B2=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20240=20(gate=20threshold=20=E2=80=94=20=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B5=D1=84=D0=B0=D0=BA=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-migration-lock-timeout.py требует lock_timeout только для NN >= 250 в tradein — порог назначен по номеру аварийной миграции 250, которую затем сняли с деплоя (#2792). Фактический максимум применённого на main — 239, то есть весь диапазон 240-249 гейтом не проверяется вообще ("проверено новых миграций: 0" = не проверено ни одного файла, не "все чисты"). Функция scan() из самого гейта, прогнанная напрямую без порогового отсечения, помечает ALTER TABLE в этом файле как блокирующий DDL без lock_timeout. trade_in_estimates — самая горячая таблица стека (история, история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка встаёт в очередь перед новыми запросами приложения. DDL на 1058 строках мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Добавлено SET LOCAL lock_timeout = '5s' сразу после BEGIN + объяснение в шапке файла, почему оно здесь при том что гейт формально не требует — чтобы не убрали как "лишнее". Порог гейта не трогаю — отдельный issue. --- .../240_trade_in_estimates_retain_until.sql | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql index 93693455..e9b0cb67 100644 --- a/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql +++ b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql @@ -48,9 +48,29 @@ -- строки со строкой в payments опосредованно через predicate purge-джобы, -- сама таблица payments здесь не читается). -- Apply after: 233_payments.sql. +-- +-- ── lock_timeout — выставлен, хотя гейт (scripts/check-migration-lock-timeout.py) +-- этот файл не проверяет ──────────────────────────────────────────────────── +-- Порог гейта для tradein (NN >= 250) — артефакт: назначен по номеру аварийной +-- миграции 250, которую затем сняли с деплоя (#2792, 29f10002). Фактический +-- максимум применённого на main — 239, то есть НИ ОДНА миграция в диапазоне +-- 240-249 (этот файл включительно) гейтом не проверяется вообще — "проверено +-- новых миграций: 0" в логе означает "не проверено ни одного файла", а не +-- "все чисты". Сама функция scan() внутри гейта, если прогнать её без +-- порогового отсечения, помечает ALTER TABLE ниже как блокирующий DDL без +-- lock_timeout. `trade_in_estimates` — самая горячая таблица стека (история, +-- история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались +-- открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка +-- встаёт в очередь ПЕРЕД новыми запросами — за ней начинают ждать обычные +-- SELECT приложения (см. `sql.md` § lock_timeout). На 1058 строках сам DDL +-- мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Красный +-- деплой по таймауту — осознанно принятый в проекте размен (честный отказ +-- лучше тихой очереди перед приложением). НЕ убирать как "гейт же не просит". BEGIN; +SET LOCAL lock_timeout = '5s'; + ALTER TABLE trade_in_estimates ADD COLUMN IF NOT EXISTS retain_until timestamptz; From 45924021a7fd74cf27aae5ad66cf82aa562bf4b0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 17:10:18 +0000 Subject: [PATCH 05/98] =?UTF-8?q?chore(tradein/db):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8C=20=D1=81=D0=BD=D0=BE=D1=81=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D1=8F=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D0=B0=20expires=5Fat=20=E2=80=94=20=D1=82=D0=B5=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D1=8C=20=D1=81=20lock=5Ftimeout=20(#2795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../250_drop_duplicate_expires_at_index.sql | 140 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + 2 files changed, 141 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql new file mode 100644 index 00000000..d725963d --- /dev/null +++ b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql @@ -0,0 +1,140 @@ +-- 250_drop_duplicate_expires_at_index.sql +-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at). +-- Возврат после #2792 (снятие с деплоя) — теперь с lock_timeout, см. #2793/#2791. +-- +-- WHY: +-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09) +-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль +-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql. +-- +-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени): +-- name indkey indclass indoption indcollation pred am +-- trade_in_estimates_expires_idx 22 3127 0 0 — btree +-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree +-- Совпадает всё: колонка, класс операторов, направление сортировки, +-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация, +-- отсутствие частичного предиката, метод доступа. Ни один не привязан к +-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них +-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует +-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты +-- FDW-пользователю). Гранты живут на таблице, не на индексе. +-- +-- ── Почему у «нулевого» дубля появились сканы ──────────────────────────────── +-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у +-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый +-- +0, новый +4). Замер 2026-08-09 16:54 UTC подтверждает картину ещё через +-- двое суток: новый 21, старый ВСЁ ЕЩЁ 234. То есть планировщик перевёл на +-- новый весь живой трафик, и это устойчивое состояние, а не переходное. +-- +-- Причина не семантическая, а физическая: индексы идентичны, но новый +-- собран позже с нуля и плотнее упакован — relpages 5 против 6 у старого, +-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск +-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost, +-- и при прочих равных выигрывает. Никакого нового запроса не появилось: +-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого, +-- 0.95 у нового) — это один и тот же класс точечных lookup'ов, просто +-- переехавший на более свежий индекс. Со временем новый забронзовеет так же +-- и они поменялись бы местами обратно. +-- +-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ──────────────────────────── +-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу +-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE +-- делал бы full scan». На проде это НЕ так. Фактический план боевого +-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод +-- 2026-08-07, перепроверено 2026-08-09 — план тот же): +-- Limit → Sort (Sort Key: expires_at) +-- → Bitmap Heap Scan Filter: (expires_at < now()) +-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at +-- Index Cond: (created_by IS NULL) +-- Задача purge ограничена `AND created_by IS NULL` (134 строки из 1061), и +-- планировщик берёт именно этот, более селективный индекс, а expires_at +-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не +-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён +-- под конкретный запрос» не существует — запрос его не использует. +-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму +-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с +-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД. +-- +-- ── Планы ДО и ПОСЛЕ ───────────────────────────────────────────────────────── +-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе: +-- меняется только имя индекса в строке плана и cost на одну страницу спуска. +-- ДО (прод, 2026-08-09 16:54 UTC): +-- Limit (cost=0.28..58.98 rows=100 width=24) +-- → Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..623.07) +-- Index Cond: (expires_at < now()) +-- ПОСЛЕ ожидается тот же узел с именем trade_in_estimates_expires_idx и +-- cost, отличающимся на спуск по одной лишней странице. Проверено на чистом +-- PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым +-- перекосом плотности: +-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84) +-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30) +-- Форма плана, Index Cond и Filter идентичны; отличается только имя. +-- +-- ── Стоимость блокировки и почему здесь SET LOCAL lock_timeout ────────────── +-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. УДЕРЖАНИЕ здесь +-- дёшево: trade_in_estimates — 1061 строка, heap 1856 kB, сносимый индекс +-- 40 kB; DROP INDEX ничего не переписывает (удаление строк каталога плюс +-- unlink файла, единицы миллисекунд). +-- +-- Дорого — ОЖИДАНИЕ выдачи лока, и это уже случилось. 2026-08-07 первая +-- редакция этого файла (без строки ниже) ждала ACCESS EXCLUSIVE 29 минут за +-- чужой аналитической psql-сессией (`CREATE TEMP TABLE tmp_res AS ...`, +-- pid 83256), вторая попытка — ещё 16. Четыре прогона деплоя красные, +-- четыре смерженных PR не доехали до прода; ждущий ACCESS EXCLUSIVE встаёт +-- в очередь ПЕРЕД новыми запросами, поэтому за ним начали ждать и обычные +-- SELECT приложения. Файл сняли с деплоя (#2792), конвенцию закрепили +-- (#2791: гейт scripts/check-migration-lock-timeout.py + .claude/rules/sql.md). +-- +-- Значение 5 s: снизу ограничено deadlock_timeout (на проде 1 s — сверено +-- 2026-08-09) — автоотмена мешающего autovacuum срабатывает только после +-- того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным +-- autovacuum. Сверху — потолок простоя очереди приложения; против +-- наблюдённых 1740 s это в 348 раз меньше. На работу ПОД локом значение не +-- влияет вообще. +-- +-- Срабатывание таймаута = красный деплой через 5 секунд с `canceling +-- statement due to lock timeout` вместо получасовой очереди. Это ожидаемое +-- поведение, а не авария: миграция не помечается применённой, повторить +-- позже. CONCURRENTLY здесь не нужен и был бы хуже: он не может выполняться +-- внутри блока транзакции, а значит файл пришлось бы оставить без +-- BEGIN/COMMIT (см. разбор механики раннера в +-- 225_listing_source_snapshots_run_id_idx.sql). +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE. +-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет. +-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы +-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует). +-- +-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс), +-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль). +-- Deploy order: standalone. Ничего не ждёт и никого не блокирует. +-- +-- Критерий «таблица тиха» (записан ДО, выполнен 2026-08-09 16:54 UTC): +-- SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation +-- WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid(); → 0 +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. EXPLAIN того же запроса показывает Index Scan using +-- trade_in_estimates_expires_idx — детерминированная проверка, доступна +-- сразу. +-- 3. pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx уходит с +-- 234. NB: наблюдаемый темп ~7 сканов/сутки (21 скан за трое суток у +-- дубля), поэтому «в течение часа» — недостаточное окно; честный срок +-- подтверждения ~сутки. Если через сутки счётчик всё ещё 234, значит +-- трафик ушёл в Seq Scan — это опровергло бы разбор выше и требовало бы +-- отката (вернуть индекс: CREATE INDEX CONCURRENTLY). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; + +COMMENT ON INDEX trade_in_estimates_expires_idx IS + 'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752/#2793). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index b73de854..d011b41a 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -242,3 +242,4 @@ 233_payments.sql 234_scrape_runs_ban_kind_unknown.sql 240_trade_in_estimates_retain_until.sql +250_drop_duplicate_expires_at_index.sql From 7b36f86ea6f123165d1c406a42f8cfc9dc5ac102 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 17:21:49 +0000 Subject: [PATCH 06/98] =?UTF-8?q?fix(tradein/domclick):=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B8=D0=BF=20=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8-?= =?UTF-8?q?=D0=BF=D1=83=D0=BB,=20=D0=B0=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=20=D1=83=D0=B7=D0=BB=D0=B0=20=D0=BD=D0=B5=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=20(#2657)=20(#2796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_2657_domclick_proxy_pool.py | 114 ++++++++++++++++++ .../test_2670_streak_and_partial_coverage.py | 2 +- .../backend/tests/test_domclick_sweep.py | 2 +- .../src/scraper_kit/orchestration/pipeline.py | 10 +- .../scraper_kit/orchestration/scheduler.py | 1 + .../scraper_kit/providers/domclick/serp.py | 50 +++++--- 6 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py diff --git a/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py b/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py new file mode 100644 index 00000000..e2a3f43a --- /dev/null +++ b/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py @@ -0,0 +1,114 @@ +"""#2657: домклик собирал 0 лотов, потому что ходил мимо прокси-пула. + +Прод 06-09.08.2026: `domclick_city_sweep` четыре прогона подряд `failed` с +`buckets=0/6, lots=0, errors=1`. Причина найдена не в QRATOR и не в куках, а в двух +сложившихся дефектах: + +1. **Домклик был единственным sweep-источником без `proxy_provider`** (#2160 P4 wiring + gap). `build_browser_fetcher(config, "domclick")` без провайдера → lease не берётся → + сайдкар идёт через статический `SCRAPER_PROXY_URL`. Этот адрес (прод + `scrape_proxies.id=1`, residential, `provider_affinity='domclick'`) с 06.08 отдаёт + `NS_ERROR_PROXY_BAD_GATEWAY` ИМЕННО на `bff-search-web.domclick.ru`. Живая проверка + 09.08 через сайдкар: тот же URL через id=1 → 500, через мобильный узел пула + (`id=10`) → 200 и `{"offersCount":704,"snippetsCount":678}` в бакете `st`. + Предпосылка миграции 173 («QRATOR банит все прокси кроме этого residential») + опровергнута: мобильный узел домкликовский BFF пускает. + +2. **Транспортная ошибка убивала весь свип на первом бакете.** `fetch_city` ловил + только `ValueError/TypeError` — ошибки РАЗБОРА. `httpx.HTTPStatusError` от сайдкара + пролетал наружу мимо цикла по `ROOM_BUCKETS`, и прогон заканчивался с `0/6`. Это же + не давало сработать ротации lease после N подряд провалов + (`browser_fetcher._LEASE_ROTATE_AFTER_FAILS`) — до неё не доживали. + +Фальсификация на старом коде: `test_scraper_leases_proxy_for_domclick` падает +`TypeError: unexpected keyword argument 'proxy_provider'`, +`test_transport_error_does_not_kill_the_whole_sweep` падает на пробросе +`HTTPStatusError` наружу. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.scheduler import _job_domclick_city_sweep +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper + + +def _config() -> SimpleNamespace: + return SimpleNamespace( + browser_http_endpoint="http://x:9000", + use_proxy_pool_browser=True, + environment="production", + ) + + +@pytest.fixture +def _no_browser() -> Any: + """Сайдкар не поднимается — нас интересует только то, ЧТО ему передали.""" + fetcher = MagicMock() + fetcher.__aenter__ = AsyncMock(return_value=fetcher) + fetcher.__aexit__ = AsyncMock(return_value=None) + with patch("scraper_kit.providers._base.BrowserFetcher", return_value=fetcher) as bf: + yield bf + + +async def test_scraper_leases_proxy_for_domclick(_no_browser: MagicMock) -> None: + """Узел берётся из пула под именем "domclick" — а не из env сайдкара.""" + provider = MagicMock() + scraper = DomClickScraper(_config(), proxy_provider=provider) + with patch.object(DomClickScraper, "_sweep_bucket", AsyncMock(return_value=None)): + await scraper.fetch_city(city_id=4) + + assert _no_browser.call_args.kwargs["proxy_provider"] is provider + assert _no_browser.call_args.kwargs["use_pool"] is True + + +async def test_transport_error_does_not_kill_the_whole_sweep(_no_browser: MagicMock) -> None: + """Отказ сайдкара на первом бакете не обнуляет прогон — остальные бакеты идут. + + Прод-форма отказа: 500 от tradein-browser с NS_ERROR_PROXY_BAD_GATEWAY в теле. + """ + scraper = DomClickScraper(_config()) + calls = {"n": 0} + + async def _sweep(self: DomClickScraper, **_: object) -> None: + calls["n"] += 1 + if calls["n"] == 1: + raise httpx.HTTPStatusError( + "Server error '500 Internal Server Error' | tradein-browser: " + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + request=MagicMock(), + response=MagicMock(), + ) + + with patch.object(DomClickScraper, "_sweep_bucket", _sweep): + await scraper.fetch_city(city_id=4) + + assert calls["n"] == len(ROOM_BUCKETS) + # Битый бакет НЕ засчитан в охват (#2670) — прогон честно неполон. + assert scraper.buckets_completed == len(ROOM_BUCKETS) - 1 + assert scraper.fetch_errors == 1 + + +async def test_scheduler_passes_pool_to_domclick_job() -> None: + """Планировщик отдаёт домклику ctx.proxy_provider — как всем остальным свипам.""" + provider = MagicMock() + ctx = SimpleNamespace( + config=_config(), + matcher=MagicMock(), + shutdown_requested=lambda: False, + proxy_provider=provider, + ) + sweep = AsyncMock(return_value=None) + with patch("scraper_kit.orchestration.scheduler.run_domclick_city_sweep", sweep): + await _job_domclick_city_sweep(MagicMock(), 1, {}, ctx) # type: ignore[arg-type] + + assert sweep.await_args.kwargs["proxy_provider"] is provider 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 index ecc31dc9..9c5cfae6 100644 --- a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py +++ b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py @@ -191,7 +191,7 @@ class _FakeFetcher: def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "scraper_kit.providers._base.build_browser_fetcher", - lambda config, source: _FakeFetcher(), + lambda config, source, **_kw: _FakeFetcher(), ) diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index e84f377e..39f869f3 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -120,7 +120,7 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey fake_fetcher = _FakeFetcher() - def _fake_build_browser_fetcher(config: object, source: str) -> _FakeFetcher: + def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher: assert source == "domclick" return fake_fetcher diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index a900f996..eb6852b3 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 @@ -3742,6 +3742,7 @@ async def run_domclick_city_sweep( config: ScraperConfig, matcher: HouseMatcher, shutdown_requested: Callable[[], bool] = lambda: False, + proxy_provider: ProxyProvider | None = None, city_id: int = DOMCLICK_DEFAULT_CITY_ID, rooms: list[int] | None = None, pages: int = 100, @@ -3753,11 +3754,16 @@ async def run_domclick_city_sweep( Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE: DomClick не поддерживает geo-radius (fetch_around → NotImplementedError), поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все - ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → shared mobile proxy. + ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → прокси-пул. Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо прямых импортов app.* (см. scraper_kit.contracts). + proxy_provider (#2657): домклик был единственным sweep-источником без пула — ходил + через статический SCRAPER_PROXY_URL сайдкара, а тот адрес с 06.08 отдаёт + NS_ERROR_PROXY_BAD_GATEWAY именно на домкликовский BFF. Теперь узел берётся из пула, + как у avito/cian/yandex, и плохой узел ротируется/банится штатными механизмами. + ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned (независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты, прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed. @@ -3807,7 +3813,7 @@ async def run_domclick_city_sweep( async def _domclick_phase() -> None: """Единственная citywide-фаза: fetch_city + save.""" nonlocal lots - async with DomClickScraper(config) as _scraper: + async with DomClickScraper(config, proxy_provider=proxy_provider) as _scraper: _scraper_ref.append(_scraper) if request_delay_sec is not None: _scraper.request_delay_sec = _resolved_delay 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 c670a298..0ddd275e 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 @@ -666,6 +666,7 @@ async def _job_domclick_city_sweep( config=ctx.config, matcher=ctx.matcher, shutdown_requested=ctx.shutdown_requested, + proxy_provider=ctx.proxy_provider, city_id=int(params.get("city_id", 4)), rooms=params.get("rooms"), pages=int(params.get("pages_per_anchor", 5)), 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 1a41094f..c9b99ba5 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 @@ -6,8 +6,9 @@ Strangler-копия (#2133) боевого `app.services.scrapers.domclick`, р (golden-parity — tests/test_scraper_kit_domclick_golden_parity.py). Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через -BrowserFetcher(source="domclick") (generic provider → shared mobile proxy). -QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy. +BrowserFetcher(source="domclick") → узел прокси-пула (#2657; до этого — статический +SCRAPER_PROXY_URL сайдкара, см. fetch_city). QRATOR банит прямые datacenter-запросы, +но пропускает через mobile proxy — проверено вживую 09.08.2026. Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (
 или bare body).
 Парсинг: _extract_json() вытаскивает первый {...} из ответа.
@@ -41,7 +42,7 @@ from scraper_kit.pricing import BisectionConfig, ProbeResult, walk_price_range
 from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
 
 if TYPE_CHECKING:
-    from scraper_kit.contracts import ScraperConfig
+    from scraper_kit.contracts import ProxyProvider, ScraperConfig
 
 logger = logging.getLogger(__name__)
 
@@ -251,12 +252,17 @@ class DomClickScraper(BaseScraper):
         config: ScraperConfig,
         *,
         delay_provider: Callable[[str], float] | None = None,
+        proxy_provider: ProxyProvider | None = None,
     ) -> None:
         super().__init__()
         # Strangler-инжекция (#2133): config даёт browser_http_endpoint для
         # BrowserFetcher, delay_provider заменяет прямой импорт
         # app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
         self._config = config
+        # #2657: proxy_provider — как у cian/yandex/avito. До этого домклик был
+        # ЕДИНСТВЕННЫМ sweep-источником без пула (#2160 P4 wiring gap) и потому ходил
+        # через статический SCRAPER_PROXY_URL сайдкара.
+        self._proxy_provider = proxy_provider
         if delay_provider is not None:
             self.request_delay_sec = delay_provider(self.name)
         self.parse_failures: int = 0
@@ -317,15 +323,16 @@ class DomClickScraper(BaseScraper):
         out_lots: list[ScrapedLot] = []
         seen_ids: set[str] = set()
 
-        # build_browser_fetcher(config, source) без proxy_provider (дефолт None) —
-        # поведенчески идентично прежнему прямому BrowserFetcher(source=..., endpoint=...):
-        # use_pool теперь читает config.use_proxy_pool_browser (#2364 caveat, тот же
-        # класс что и avito) вместо хардкода False, но BrowserFetcher._pool_proxy
-        # вычисляет `use_pool and proxy_provider is not None` — при proxy_provider=None
-        # результат всегда False независимо от значения флага (см. _base.py
-        # build_browser_fetcher docstring: domclick пока не подключён к browser-proxy-
-        # пулу, #2160 P4 wiring gap). Поведение НЕ меняется.
-        async with build_browser_fetcher(self._config, "domclick") as fetcher:
+        # #2657: proxy_provider прокинут — до этого домклик единственный из sweep-
+        # источников ходил без пула (#2160 P4 wiring gap), т.е. через статический
+        # SCRAPER_PROXY_URL сайдкара. Прод 06-09.08: этот адрес отдаёт
+        # NS_ERROR_PROXY_BAD_GATEWAY именно на bff-search-web.domclick.ru (проверено
+        # вживую 09.08: через него 500, через мобильный узел пула — 200 и
+        # snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
+        # proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
+        async with build_browser_fetcher(
+            self._config, "domclick", proxy_provider=self._proxy_provider
+        ) as fetcher:
             for bucket in ROOM_BUCKETS:
                 logger.info(
                     "domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
@@ -350,15 +357,22 @@ class DomClickScraper(BaseScraper):
                         bucket,
                     )
                     # #2600 п.1: fetcher (lease) ещё жив — `async with` вокруг этого
-                    # цикла не закрылся, мы внутри его тела. no-op сегодня (домклик
-                    # SERP собирается БЕЗ proxy_provider, #2160 P4 wiring gap — см.
-                    # build_browser_fetcher выше), но провода готовы на будущее.
+                    # цикла не закрылся, мы внутри его тела. С #2657 (proxy_provider
+                    # прокинут выше) это уже не no-op: узел уходит в
+                    # scrape_proxy_source_bans и следующий acquire("domclick") его не
+                    # выдаст.
                     fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
                     break
-                except (ValueError, TypeError) as exc:
+                except Exception as exc:
                     # Defensive: bucket-level ошибка не должна убивать весь sweep.
-                    # _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
-                    # но если что-то всё же всплыло — переходим к следующему бакету.
+                    # _count/_paginate глотают ошибки РАЗБОРА per-fetch (fetch_errors++),
+                    # но транспортные (httpx.HTTPStatusError от сайдкара, таймаут узла)
+                    # летели мимо и убивали прогон на ПЕРВОМ же бакете — прод 06-09.08:
+                    # buckets=0/6, lots=0, четыре дня подряд. Это же лишало смысла
+                    # ротацию lease после N подряд провалов (browser_fetcher
+                    # _LEASE_ROTATE_AFTER_FAILS): до неё просто не доживали. Ловим
+                    # Exception, а не BaseException — CancelledError (SIGTERM-drain,
+                    # watchdog asyncio.wait_for) обязан пройти насквозь.
                     self.fetch_errors += 1
                     logger.warning(
                         "domklik: bucket rooms=%r failed (%s) — skipping to next bucket",

From f1f2bca2e93007bd02ac09f8af891bac94982a3e Mon Sep 17 00:00:00 2001
From: bot-backend 
Date: Sun, 9 Aug 2026 17:26:18 +0000
Subject: [PATCH 07/98] =?UTF-8?q?fix(tradein/deactivate):=20TTL=20=D0=BD?=
 =?UTF-8?q?=D0=B5=20=D1=81=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D0=BE?=
 =?UTF-8?q?=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF?=
 =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D1=83=20=D0=BD=D0=B8?=
 =?UTF-8?q?=D0=B6=D0=B5=20=D1=81=D0=BE=D0=B1=D1=81=D1=82=D0=B2=D0=B5=D0=BD?=
 =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=86=D0=B8=D0=BA=D0=BB=D0=B0=20?=
 =?UTF-8?q?=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B0=20(#2797)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../backend/app/services/product_handlers.py  |   8 +
 .../app/tasks/deactivate_stale_avito.py       | 156 +++++++-
 .../test_deactivate_stale_revisit_floor.py    | 334 ++++++++++++++++++
 3 files changed, 494 insertions(+), 4 deletions(-)
 create mode 100644 tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py

diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py
index 29b6aaab..64ba11e2 100644
--- a/tradein-mvp/backend/app/services/product_handlers.py
+++ b/tradein-mvp/backend/app/services/product_handlers.py
@@ -218,6 +218,7 @@ async def _job_deactivate_stale(
     from app.core.config import settings as _settings
     from app.tasks.deactivate_stale_avito import (
         DEFAULT_MIN_CONFIRMATIONS,
+        DEFAULT_REVISIT_FLOOR_QUANTILE,
         deactivate_stale_listings,
     )
 
@@ -229,6 +230,12 @@ async def _job_deactivate_stale(
     # получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
     # источнику пороги приходят из default_params (миграция 219).
     min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
+    # Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию:
+    # незасеянное расписание не должно снимать объявления по порогу ниже собственного
+    # хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0.
+    revisit_floor_quantile: float = params.get(
+        "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
+    )
 
     loop = asyncio.get_event_loop()
     await loop.run_in_executor(
@@ -241,6 +248,7 @@ async def _job_deactivate_stale(
             segments=segments,
             staleness_column=staleness_column,
             min_confirmations=min_confirmations,
+            revisit_floor_quantile=revisit_floor_quantile,
         ),
     )
 
diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py
index 1f800160..cdab053b 100644
--- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py
+++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py
@@ -24,6 +24,7 @@ TTL для avito берётся из settings.avito_stale_ttl_days (env AVITO_ST
 from __future__ import annotations
 
 import logging
+from math import ceil
 from typing import Any
 
 from sqlalchemy import text
@@ -127,6 +128,107 @@ DEFAULT_MIN_CONFIRMATIONS = 500
 _CONFIRMATIONS_SEGMENT_FILTER = "\n          AND listing_segment = ANY(CAST(:segments AS text[]))"
 
 
+# ── Пол TTL по измеренному циклу переобхода (#2659) ───────────────────────────
+# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на
+# вопрос, из-за которого заведён #2659: «а достаточно ли ttl_days, чтобы молчание
+# означало снятие?». Пока свип возвращается к строке реже, чем раз в ttl_days,
+# TTL меряет НАШУ выборку, а не жизнь объявления, — и источник при этом полностью
+# здоров, так что гейт молчит.
+#
+# ЗАМЕР НА ПРОДЕ 2026-08-09, из-за которого этот пол существует.
+# С момента деплоя гейта (06.08) TTL снял 1 028 строк; 127 из них (12.4%) УЖЕ снова
+# активны — свип нашёл их живыми через 1-3 суток и вернул сам (upsert в
+# scraper_kit/base.py ставит is_active = true). В единственном городе с настоящим
+# покрытием доля ложных снятий 100%:
+#   cian   Екатеринбург  103 снято → 103 снова активны
+#   yandex Екатеринбург   24 снято →  24 снова активны
+#   cian/yandex без города 901 снято → 0 вернулись (их свип не обходит вовсе)
+# Возраст на момент снятия у всех 127: 29.9..30.3 суток при TTL=30 — то есть TTL
+# срабатывал ровно на границе, а свип возвращался к строке на 31-34-е сутки.
+#
+# ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ НОВОЙ КОНСТАНТОЙ. Разрывы переобхода, суток
+# (listing_source_snapshots, 40 суток, посчитано по срезу TTL-джобы):
+#   источник/сегмент      p90    p99   TTL сейчас   TTL/p99
+#   domklik  vtorichka     1.9    3.1      14         4.5   ← сплошное суточное покрытие
+#   cian     vtorichka    10.9   26.6      30         1.1
+#   yandex   vtorichka     5.7   43.0      30         0.7
+#   avito    vtorichka    29.1   42.1      10         0.24  ← отсюда 9 033 строки
+# Домклик — контрольная группа: при почти полном суточном обходе TTL=14 лежит в
+# 4.5 раза выше хвоста, и снятие у него действительно означает снятие. У остальных
+# трёх порог ниже собственного хвоста обхода — руками подобранное число и есть
+# корень #2659, поэтому чинить его вторым руками подобранным числом бессмысленно.
+#
+# ЧТО МЕРЯЕМ ВМЕСТО КОНСТАНТЫ: факт, а не оценку. «Какой самый большой возраст, при
+# котором свип за последнее окно ДОКАЗАЛ, что объявление живо» — то есть насколько
+# старую строку он только что нашёл на площадке. Если свип буквально вчера вернул к
+# жизни строку, молчавшую 40 суток, то 30 суток молчания не доказывают ничего.
+# Пол = квантиль этого распределения, эффективный TTL = max(ttl_days, пол).
+#
+# Считается по ТОМУ ЖЕ срезу (source + segments) и по ТОЙ ЖЕ колонке свежести, что
+# и UPDATE. Предыдущее наблюдение берётся из listing_source_snapshots — единственной
+# истории свежести, что у нас есть; расхождение listings. и
+# listing_sources.last_seen_at замерено на проде и не превышает 0.5 суток в среднем
+# (максимум 0), что на шкале 30-70 суток шум.
+#
+# КВАНТИЛЬ — калибровочная ручка, не догма. 0.99 подобран по требованию «пол обязан
+# накрыть 127 доказанных ложных снятий», у которых возраст был 29.9..30.3: замер
+# того же запроса на проде даёт 34.0 для cian/vtorichka и 74.3 для yandex/vtorichka.
+# Ниже 0.99 опускать нельзя без нового замера. Ручка живёт в default_params
+# расписания (revisit_floor_quantile), 0 -> пол выключен.
+#
+# ПОБОЧНЫЙ ЭФФЕКТ, КОТОРЫЙ ЗДЕСЬ НАМЕРЕННЫЙ: после провала сбора хвост разрывов
+# распухает (свип разгребает завал и находит очень старые строки), пол поднимается,
+# и деактивация замирает сама — без отдельного детектора банов. Когда завал разобран,
+# хвост схлопывается и пол опускается обратно. Это ровно то поведение, которого
+# issue просил от «гейта по банам», но выраженное через результат, а не через причину.
+#
+# ПОТОЛОК: пол не может превысить глубину истории снимков. Если снимок за нужную
+# дату не писался (дыры на проде есть — 30.07, 01.08), берётся ближайший более
+# ранний; при полном отсутствии снимков пол не считается и TTL остаётся как задан.
+DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99
+
+_REVISIT_FLOOR_SEGMENT_FILTER = "\n          AND l.listing_segment = ANY(CAST(:segments AS text[]))"
+
+
+def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any:
+    """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива.
+
+    Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт
+    разрыв переобхода в сутках; берём его квантиль по срезу source+segments.
+    Только строки, у которых свежесть реально сдвинулась, — то есть выжившие,
+    а не «мы к ним не приходили».
+
+    staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
+    Значения — param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type).
+    """
+    segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER if with_segments else ""
+    return text(
+        f"""
+        SELECT percentile_disc(CAST(:revisit_quantile AS double precision))
+               WITHIN GROUP (
+                   ORDER BY EXTRACT(epoch FROM (l.{staleness_column} - prev.last_seen_at))
+                            / 86400.0
+               )
+        FROM listings l
+        JOIN listing_sources ls
+          ON ls.listing_id = l.id
+         AND ls.ext_source = l.source
+        JOIN listing_source_snapshots prev
+          ON prev.listing_source_id = ls.id
+         AND prev.snapshot_date = (
+                 SELECT max(snapshot_date)
+                 FROM listing_source_snapshots
+                 WHERE snapshot_date
+                       <= CURRENT_DATE - CAST(:health_window_days AS integer)
+             )
+        WHERE l.source = :listing_source
+          AND l.{staleness_column}
+              > NOW() - CAST(:health_window_days || ' days' AS interval)
+          AND l.{staleness_column} > prev.last_seen_at{segment_filter}
+        """
+    )
+
+
 def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
     """SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
 
@@ -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 08/98] =?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 11/98] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D0=B7=D0=B5?= =?UTF-8?q?=D0=BB,=20=D0=B7=D0=B0=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?,=20=D0=B4=D0=BE=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=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 12/98] =?UTF-8?q?fix(tradein/proxy):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=20=D1=81=D0=BF=D1=80=D0=B0=D1=88=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=83=D1=8E=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D1=83,=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B4=D0=B8=D0=BA=D1=82=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=BF=D0=B0=D1=80=D1=83=20=C2=AB?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D0=BB=20=C3=97=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=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 13/98] =?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 14/98] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B0=20?= =?UTF-8?q?=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 15/98] =?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 16/98] =?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 17/98] =?UTF-8?q?fix(tradein/newbuilding):=20=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=20=D0=B2=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D1=83=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?(#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 18/98] =?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 19/98] =?UTF-8?q?fix(tradein/avito):=20=D1=80=D0=B5=D0=B9?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=BE=D0=BC=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D1=83=D0=B5?= =?UTF-8?q?=D0=B7=D0=B6=D0=B0=D1=82=D1=8C=20=D0=B2=20=D0=B0=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D1=81=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=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 20/98] =?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 21/98] =?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 22/98] =?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 23/98] =?UTF-8?q?fix(tradein/dedup):=20=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=BA=20=D1=81=D1=85=D0=BB=D0=BE=D0=BF=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D1=8F=D0=B5=D0=BC=D1=8B=D0=BC?= =?UTF-8?q?=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=D0=BC,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=BE=D0=B9=20(#2690)?= =?UTF-8?q?=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 24/98] =?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 25/98] =?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 26/98] =?UTF-8?q?fix(tradein/estimate):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=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 Количество объявлений по аналогичным объектам {_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 27/98] =?UTF-8?q?feat(tradein):=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA,=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=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="Раздел «Профиль» скоро появится" > -
- -