From 0535fa209a4af8ec6a3d96222b77f1d100388700 Mon Sep 17 00:00:00 2001
From: lekss361
Date: Thu, 6 Aug 2026 18:27:39 +0000
Subject: [PATCH 001/124] =?UTF-8?q?chore(tradein/db):=20=D1=83=D0=B1=D0=BE?=
=?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD?=
=?UTF-8?q?=D1=8B=D1=85=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86,=20=D0=B4?=
=?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B5=D0=B9=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?=
=?UTF-8?q?=D1=81=D0=BE=D0=B2=20=D0=B8=20=D0=B7=D0=B2=D1=91=D0=B7=D0=B4?=
=?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B2=20v=5Fdata=5Fquality=20(#2746?=
=?UTF-8?q?)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Миграции 222 (DROP 2 tmp-таблиц + 5 строгих дублей индексов + v_data_quality с явным списком колонок) и 225 (CREATE INDEX CONCURRENTLY под FK listing_source_snapshots.run_id).
Проверено на прод-БД в BEGIN…ROLLBACK и на чистой схеме (полный bootstrap 225 миграций в одноразовом контейнере).
---
.../backend/data/sql/222_db_audit_cleanup.sql | 140 ++++++++++++++++++
...25_listing_source_snapshots_run_id_idx.sql | 42 ++++++
.../backend/data/sql/_manifest_applied.txt | 9 ++
3 files changed, 191 insertions(+)
create mode 100644 tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql
create mode 100644 tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql
diff --git a/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql
new file mode 100644
index 00000000..35bfb069
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql
@@ -0,0 +1,140 @@
+-- 222_db_audit_cleanup.sql
+-- Уборка по итогам ручного аудита схемы tradein (2026-08-06). Три независимых
+-- части, порядок между ними не важен (разные объекты, нет пересекающихся
+-- зависимостей). Идемпотентно целиком: IF EXISTS везде, CREATE OR REPLACE VIEW.
+--
+-- ── A. Временные таблицы разовой чистки 02.07 ─────────────────────────────────
+-- tmp_purged_junk_houses_0702 / tmp_purged_junk_links_0702 — снэпшоты записей,
+-- вычищенных вручную 2026-07-02. Проверено на проде перед этой миграцией:
+-- * обе существуют под этими именами, суммарно 2.9 МБ
+-- (tmp_purged_junk_houses_0702 = 1608 kB, tmp_purged_junk_links_0702 = 1272 kB);
+-- * pg_constraint: ни один FK НЕ ссылается на них (confrelid пусто);
+-- * pg_depend: ни view, ни другой объект их не использует (только сами по себе).
+-- Дальше не нужны — были just-in-case снэпшотом на случай отката чистки, месяц
+-- прошёл без претензий.
+--
+-- ── B. Пять строгих дублей индексов ───────────────────────────────────────────
+-- Строгий дубль = тот же access method + тот же УПОРЯДОЧЕННЫЙ список колонок
+-- (включая ASC/DESC/NULLS) + тот же частичный предикат (или оба NULL) + тот же
+-- opclass, независимо от UNIQUE-флага и имени. Проверено запросом по
+-- pg_index/pg_stat_user_indexes/pg_opclass на проде — найдено РОВНО 5 пар (не 6,
+-- см. примечание ниже), в каждой паре оставляем индекс, несущий UNIQUE-constraint
+-- (дропнуть его нельзя без дропа constraint'а), дропаем чистый btree-дубль:
+--
+-- agents: DROP agents_source_ext_idx
+-- (дубль agents_ext_source_ext_agent_id_key, btree (ext_source, ext_agent_id);
+-- 2 скана за всё время — планировщик и так предпочитал unique-версию)
+-- ekb_geoportal_buildings: DROP ix_ekb_geoportal_buildings_street_house
+-- (дубль ekb_geoportal_buildings_street_norm_house_norm_key,
+-- btree (street_norm, house_norm); 13 945 сканов, но unique-версия того же
+-- определения покрывает те же запросы — 70 702 скана на ней)
+-- house_placement_history: DROP hph_source_item_idx
+-- (дубль house_placement_history_source_ext_item_id_key,
+-- btree (source, ext_item_id); 0 сканов — полностью мёртв)
+-- house_reviews: DROP hr_source_ext_idx
+-- (дубль house_reviews_source_ext_review_id_key, btree (source, ext_review_id);
+-- 15 сканов)
+-- sellers: DROP sellers_source_idx
+-- (дубль sellers_source_ext_seller_id_key, btree (source, ext_seller_id);
+-- 2 скана)
+--
+-- ПРИМЕЧАНИЕ (расхождение с ожиданием «шесть»): при систематической проверке
+-- (3 независимых метода: нормализованный DDL-текст, сравнение indkey/indoption,
+-- сравнение opclass) строгих дублей найдено 5, не 6. Два похожих кандидата
+-- ЦЕЛЕНАПРАВЛЕННО исключены — их «дубль» только по списку колонок, а порядок
+-- сортировки различается (ASC,ASC у unique-версии против ASC,DESC у второй),
+-- то есть это ТОТ ЖЕ класс исключения, что explicitly подтверждённые
+-- idx_lss_source_date/listings_snapshots_listing_date_idx (#2607, см. миграцию 225):
+-- * offer_price_history: oph_listing_time_idx (listing_id ASC, change_time DESC)
+-- против offer_price_history_listing_change_uq (listing_id ASC, change_time ASC)
+-- * houses_price_dynamics: hpd_house_dim_idx (..., month_date DESC)
+-- против houses_price_dynamics_dim_key (..., month_date ASC)
+-- Оба НЕ тронуты. Если «шесть» подразумевали один из них — нужно явное
+-- подтверждение, что смешанный порядок сортировки в конкретном запросе не
+-- используется (тем же способом, каким для idx_lss_source_date подтверждено
+-- обратное).
+--
+-- ── C. v_data_quality — явный список колонок вместо SELECT * ─────────────────
+-- Сейчас: `WITH active_listings AS (SELECT * FROM listings WHERE is_active = true)`.
+-- Postgres разворачивает `*` в CREATE VIEW time в полный список колонок listings
+-- (89 на момент миграции) и фиксирует pg_depend на КАЖДУЮ из них — это то самое
+-- уже задокументированное в 214/216 предупреждение («View-зависимость: v_data_quality
+-- содержит SELECT * FROM listings, что фиксирует column-level зависимость на ВСЕ
+-- колонки»), которое обязывало делать DROP VIEW → DROP COLUMN → CREATE VIEW при
+-- каждой чистке listings.
+-- Фактически используются только 6 колонок active_listings ниже по телу view:
+-- id — IN (SELECT active_listings.id FROM active_listings)
+-- lat — pct_geocoded
+-- cadastral_number — pct_cadastr
+-- description — pct_description
+-- house_id_fk — JOIN houses h ON h.id = l.house_id_fk (pct_year_built)
+-- is_active — WHERE-фильтр самого CTE (создаёт зависимость даже не будучи
+-- в SELECT-списке, поэтому перечислен явно для наглядности)
+-- Поведение view НЕ меняется — только явный список вместо *. Тело SELECT (16
+-- выходных колонок) скопировано без изменений из 216_dead_code_sweep.sql.
+-- COMMENT ON VIEW сохраняется автоматически (CREATE OR REPLACE VIEW не сбрасывает
+-- комментарий).
+--
+-- Dependencies: 002_core_tables.sql (listings), 214_drop_dead_run_metrics.sql,
+-- 216_dead_code_sweep.sql (последний DDL v_data_quality).
+-- Идемпотентно: DROP TABLE IF EXISTS / DROP INDEX IF EXISTS / CREATE OR REPLACE VIEW.
+
+BEGIN;
+
+-- ── A ──────────────────────────────────────────────────────────────────────
+DROP TABLE IF EXISTS tmp_purged_junk_houses_0702, tmp_purged_junk_links_0702;
+
+-- ── B ──────────────────────────────────────────────────────────────────────
+DROP INDEX IF EXISTS agents_source_ext_idx;
+DROP INDEX IF EXISTS ix_ekb_geoportal_buildings_street_house;
+DROP INDEX IF EXISTS hph_source_item_idx;
+DROP INDEX IF EXISTS hr_source_ext_idx;
+DROP INDEX IF EXISTS sellers_source_idx;
+
+-- ── C ──────────────────────────────────────────────────────────────────────
+CREATE OR REPLACE VIEW v_data_quality AS
+WITH active_listings AS (
+ SELECT id, lat, cadastral_number, description, house_id_fk, is_active
+ FROM listings
+ WHERE is_active = true
+)
+SELECT
+ (SELECT count(*) FROM houses) AS houses_total,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
+ (SELECT count(*) FROM (
+ SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
+ ) sub) AS houses_2plus_sources,
+ (SELECT count(*) FROM (
+ SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
+ ) sub) AS houses_3plus_sources,
+ (SELECT count(*) FROM active_listings) AS listings_active,
+ (SELECT count(*) FROM (
+ SELECT listing_id FROM listing_sources
+ WHERE listing_id IN (SELECT id FROM active_listings)
+ GROUP BY listing_id HAVING count(*) >= 2
+ ) sub) AS listings_dedup_2sources,
+ (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
+ (SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr,
+ (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
+ (SELECT count(*) FROM active_listings l
+ JOIN houses h ON h.id = l.house_id_fk
+ WHERE h.year_built IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago;
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql
new file mode 100644
index 00000000..e783502e
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql
@@ -0,0 +1,42 @@
+-- 225_listing_source_snapshots_run_id_idx.sql
+-- Индекс под внешний ключ listing_source_snapshots.run_id → scrape_runs(id)
+-- ON DELETE SET NULL. Индекса на run_id нет (проверено \d listing_source_snapshots
+-- на проде): есть только listing_source_snapshots_pkey (listing_source_id,
+-- snapshot_date), idx_lss_source_date (listing_source_id, snapshot_date DESC),
+-- idx_lss_snapshot_date (snapshot_date DESC) — ни один не начинается с run_id.
+-- Таблица — 2 872 080 строк (reltuples), 696 MB (pg_total_relation_size).
+-- Без индекса каждый DELETE из scrape_runs делает Seq Scan по 2.87М строк, чтобы
+-- обнулить run_id у зависимых снэпшотов (ON DELETE SET NULL).
+--
+-- ── Почему CONCURRENTLY и почему в файле нет BEGIN/COMMIT ────────────────────
+-- CREATE INDEX CONCURRENTLY не может выполняться внутри блока транзакции
+-- (Postgres: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block").
+-- На 2.87М строк / 696 MB обычный CREATE INDEX держит ACCESS EXCLUSIVE lock на
+-- время сборки (секунды-десятки секунд под нагрузкой) — на боевой таблице,
+-- которую пишет активный скрейпинг, это неприемлемо; нужен CONCURRENTLY.
+--
+-- Более ранние миграции с похожей потребностью (117, 120, 134, 137) сознательно
+-- ОТКАЗАЛИСЬ от CONCURRENTLY с комментарием «deploy migration runner wraps each
+-- file in an explicit transaction (BEGIN/COMMIT)». Перепроверено перед этой
+-- миграцией: .forgejo/workflows/deploy-tradein.yml, шаг применения миграций
+-- (`for sql_file in ...; psql -v ON_ERROR_STOP=on < "$sql_file"`) НЕ добавляет
+-- собственный BEGIN/COMMIT и не передаёт `-1`/`--single-transaction` — транзакция
+-- в тех файлах возникала ТОЛЬКО из-за их же собственных BEGIN;...COMMIT; внутри
+-- файла, не из-за механизма деплоя. Эмпирическое подтверждение: в data/sql уже
+-- есть применённые на проде миграции без BEGIN/COMMIT вовсе (003_seed_deals.sql,
+-- 005_geocode_tracking.sql, 218_scrape_runs_ban_kind.sql,
+-- 223_scrape_runs_time_columns_meaning.sql) — psql выполняет их операторы с
+-- autocommit по одному, деплой не падает. Поэтому здесь BEGIN/COMMIT сознательно
+-- ОПУЩЕН: файл — это один самостоятельный CREATE INDEX CONCURRENTLY, выполняемый
+-- psql в autocommit-режиме.
+--
+-- Идемпотентно: IF NOT EXISTS. (Единственный неидемпотентный случай — если
+-- предыдущая попытка CONCURRENTLY была прервана и оставила INVALID индекс с тем
+-- же именем; тогда IF NOT EXISTS молча НЕ пересоздаст его валидным, и потребуется
+-- ручной `DROP INDEX CONCURRENTLY idx_lss_run_id;` перед повтором — это штатное
+-- поведение CONCURRENTLY, не специфика этого файла.)
+-- Dependencies: 079_listing_source_history.sql (создала таблицу и оба FK).
+-- Deploy order: standalone, независим от 222_db_audit_cleanup.sql.
+
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lss_run_id
+ ON listing_source_snapshots (run_id);
diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt
index 7d71c6d7..52a76097 100644
--- a/tradein-mvp/backend/data/sql/_manifest_applied.txt
+++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt
@@ -230,4 +230,13 @@
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
+#
+# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент
+# правки они уже слиты в main и применены на проде (см. _schema_migrations),
+# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш;
+# self-maintenance-контракт (см. докстринг test_migrations_manifest.py)
+# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для
+# 222/225 по прецеденту 233_payments.sql.
+222_db_audit_cleanup.sql
+225_listing_source_snapshots_run_id_idx.sql
233_payments.sql
From ac870b0c582a291d22a7233c588db876f748673a Mon Sep 17 00:00:00 2001
From: lekss361
Date: Thu, 6 Aug 2026 18:47:35 +0000
Subject: [PATCH 002/124] =?UTF-8?q?fix(ptica):=20=D1=81=D0=BA=D1=80=D0=B0?=
=?UTF-8?q?=D0=B1=20=D0=9F=D0=94=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20?=
=?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=BE=D0=B9=20=D0=B2?=
=?UTF-8?q?=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3?=
=?UTF-8?q?=20+=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BF?=
=?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20=D0=9D=D0=94=D0=A1=20?=
=?UTF-8?q?=D0=B2=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=D1=85=20(#2457)?=
=?UTF-8?q?=20(#2749)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PII scrub wired to BOTH channels (before_send AND before_send_transaction) in app/main.py and app/workers/celery_app.py.
Before: Celery had no before_send at all, and before_send_transaction was URL-only while glitchtip_traces_sample_rate defaults to 0.05 - the Starlette integration puts request.data on transaction scope exactly as on error scope, so lead bodies leaked through the transaction channel.
Keys: full MERA set (client_name/client_phone/client_email/phone/email/name) plus company/message from PilotRequestInput.
VAT label: 'NDS (parking)' -> 'NDS (parking + commercial)' in DOCX/HTML exporters - financial.py computes VAT over parking AND non-residential.
---
backend/app/main.py | 11 +-
backend/app/observability/sentry_scrub.py | 107 ++++++-
.../services/exporters/full_report_docx.py | 2 +-
.../services/exporters/full_report_html.py | 2 +-
backend/app/workers/celery_app.py | 10 +-
backend/tests/test_sentry_init.py | 261 +++++++++++++++++-
6 files changed, 381 insertions(+), 12 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index ee3969ec..7162bfa0 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -48,7 +48,7 @@ from app.core import auth_db
from app.core.audit_middleware import audit_log_middleware
from app.core.auth import get_role
from app.core.config import settings
-from app.observability.sentry_scrub import scrub_sensitive_query
+from app.observability.sentry_scrub import scrub_event
from app.services.auth_session import resolve_session_token
logger = logging.getLogger(__name__)
@@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
# (middleware, маршруты) видели активный client с самого старта процесса.
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
if settings.glitchtip_dsn:
+ # before_send И before_send_transaction — ОБА на scrub_event (#2457-review):
+ # Starlette-интеграция кладёт request.data на transaction-scope так же, как
+ # на error-scope, поэтому голый scrub_sensitive_query (только URL) на
+ # before_send_transaction оставлял бы PII-канал открытым при любом
+ # glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring).
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
@@ -82,8 +87,8 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
- before_send=scrub_sensitive_query,
- before_send_transaction=scrub_sensitive_query,
+ before_send=scrub_event,
+ before_send_transaction=scrub_event,
integrations=[
StarletteIntegration(),
FastApiIntegration(),
diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py
index af936c31..d40911ae 100644
--- a/backend/app/observability/sentry_scrub.py
+++ b/backend/app/observability/sentry_scrub.py
@@ -1,17 +1,63 @@
-"""Хук before_send_transaction для GlitchTip/Sentry SDK.
+"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK.
-Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы
-секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip
-через HttpxIntegration performance-spans.
+`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед
+отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в
+GlitchTip через HttpxIntegration performance-spans.
+
+`scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone /
+client_email / phone / email / name / company / message) из events перед
+отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на
+sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в
+request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в
+`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/
+`message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный
+вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет
+сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/
+sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/
+client_email — Птица их не использует сегодня, но одинаковый механизм на
+оба продукта проще сопровождать), плюс `company`/`message`, специфичные для
+`PilotRequestInput` (#2457-review).
+
+`scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым
+надо вешать ОБА канала — `before_send` И `before_send_transaction`.
+Starlette-интеграция кладёт тело запроса в `request_info["data"]` на
+transaction-scope точно так же, как на error-scope (scope-обработчики для
+transactions НЕ пропускаются — пропуск бывает только на availability-чеках).
+Если повесить PII-scrub только на `before_send`, а `before_send_transaction`
+оставить на голом `scrub_sensitive_query` — PII продолжит течь через
+transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review,
+воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в
+транзакцию с полным телом).
"""
from __future__ import annotations
+import logging
import re
from typing import Any
from sentry_sdk.types import Event
+logger = logging.getLogger(__name__)
+
+_REDACTED = "[REDACTED]"
+# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ
+# (client_name/client_phone/client_email/phone/email/name, #396) + company/
+# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля
+# свободного текста (#2457-review).
+_PII_KEYS = frozenset(
+ {
+ "client_name",
+ "client_phone",
+ "client_email",
+ "phone",
+ "email",
+ "name",
+ "company",
+ "message",
+ }
+)
+
_SENSITIVE_PARAM_RE = re.compile(
r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)",
re.IGNORECASE,
@@ -47,3 +93,56 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None:
request["url"] = _redact(request["url"])
return event
+
+
+def _scrub(obj: Any) -> None:
+ """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
+ if isinstance(obj, dict):
+ for key, value in obj.items():
+ if isinstance(key, str) and key.lower() in _PII_KEYS:
+ obj[key] = _REDACTED
+ else:
+ _scrub(value)
+ elif isinstance(obj, list):
+ for item in obj:
+ _scrub(item)
+
+
+def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
+ """Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction)
+ перед отправкой в GlitchTip.
+
+ Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list),
+ заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event
+ (не None) — иначе SDK дропнет отчёт целиком.
+ """
+ if not isinstance(event, dict):
+ return event
+ request = event.get("request")
+ if isinstance(request, dict):
+ _scrub(request.get("data"))
+ _scrub(event.get("extra"))
+ _scrub(event.get("contexts"))
+ return event
+
+
+def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None:
+ """Composed `before_send` / `before_send_transaction` handler: PII-scrub +
+ URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module
+ docstring (#2457-review): transaction-scope несёт `request.data` точно так
+ же, как error-scope.
+
+ try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в
+ `capture_internal_exceptions`, который при исключении внутри хендлера
+ ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что
+ редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты
+ покрытия редактора: лучше отправить событие в состоянии "сколько успели
+ отредактировать до сбоя", чем не отправить вообще и молча остаться без
+ сигнала в мониторинге.
+ """
+ try:
+ scrub_pii_event(event, hint)
+ scrub_sensitive_query(event, hint)
+ except Exception:
+ logger.exception("sentry_scrub.scrub_event: handler failed, sending event as-is")
+ return event
diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py
index d85d24ca..6d6f139e 100644
--- a/backend/app/services/exporters/full_report_docx.py
+++ b/backend/app/services/exporters/full_report_docx.py
@@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N
["Земля", _fmt_money_signed(financial.get("land_rub"))],
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
- ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
+ ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py
index c71725af..6087e4a4 100644
--- a/backend/app/services/exporters/full_report_html.py
+++ b/backend/app/services/exporters/full_report_html.py
@@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str:
["Земля", _fmt_money_signed(financial.get("land_rub"))],
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
- ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
+ ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py
index 1268bfe6..e52b8f5b 100644
--- a/backend/app/workers/celery_app.py
+++ b/backend/app/workers/celery_app.py
@@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.core.config import settings
-from app.observability.sentry_scrub import scrub_sensitive_query
+from app.observability.sentry_scrub import scrub_event
logger = logging.getLogger(__name__)
@@ -23,6 +23,11 @@ logger = logging.getLogger(__name__)
# чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного
# вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента.
if settings.glitchtip_dsn:
+ # before_send И before_send_transaction — ОБА на scrub_event (#2457-review,
+ # см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker
+ # вообще не скрабил error-события (тут before_send не было), а
+ # before_send_transaction был на голом scrub_sensitive_query (только URL) —
+ # оба канала пропускали PII.
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
@@ -30,7 +35,8 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
- before_send_transaction=scrub_sensitive_query,
+ before_send=scrub_event,
+ before_send_transaction=scrub_event,
integrations=[
CeleryIntegration(monitor_beat_tasks=True),
SqlalchemyIntegration(),
diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py
index 75538a31..73dd80c4 100644
--- a/backend/tests/test_sentry_init.py
+++ b/backend/tests/test_sentry_init.py
@@ -2,14 +2,21 @@
Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init()
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
-и что scrub_sensitive_query redact-ит api keys из URL spans.
+что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event
+redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/
+company/message) из request.data/extra/contexts, и что composed-хендлер
+scrub_event реально повешен на ОБА канала (before_send И
+before_send_transaction) в main.py/celery_app.py (#2457-review).
"""
import os
+import pathlib
from unittest.mock import patch
import sentry_sdk
+_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1]
+
def test_sdk_imports_without_error() -> None:
"""Все интеграции импортируются без ModuleNotFoundError."""
@@ -156,3 +163,255 @@ def test_scrub_handles_missing_spans() -> None:
event: dict = {"request": {"url": "https://example.com"}}
result = scrub_sensitive_query(event, {})
assert result["request"]["url"] == "https://example.com"
+
+
+# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ────────────
+#
+# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий
+# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data,
+# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk
+# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором.
+
+
+def test_scrub_pii_redacts_request_data() -> None:
+ """scrub_pii_event заменяет client_name/phone/email в request.data."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {
+ "request": {
+ "data": {
+ "client_name": "Иван Иванов",
+ "phone": "+79991234567",
+ "email": "ivan@example.com",
+ "address": "Екатеринбург, ул. Ленина 1",
+ }
+ }
+ }
+ result = scrub_pii_event(event, {})
+ data = result["request"]["data"]
+ assert data["client_name"] == "[REDACTED]"
+ assert data["phone"] == "[REDACTED]"
+ assert data["email"] == "[REDACTED]"
+ # non-PII поле не трогаем
+ assert data["address"] == "Екатеринбург, ул. Ленина 1"
+
+
+def test_scrub_pii_redacts_pilot_request_company_and_message() -> None:
+ """scrub_pii_event заменяет company/message — свободный текст
+ PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают
+ телефоны/имена/адреса, а не только фиксированные name/phone/email
+ (#2457-review)."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {
+ "request": {
+ "data": {
+ "company": "ООО Ромашка",
+ "message": "Меня зовут Иван, звоните на +79991234567",
+ "source": "landing",
+ }
+ }
+ }
+ result = scrub_pii_event(event, {})
+ data = result["request"]["data"]
+ assert data["company"] == "[REDACTED]"
+ assert data["message"] == "[REDACTED]"
+ # non-PII поле не трогаем
+ assert data["source"] == "landing"
+
+
+def test_scrub_pii_redacts_client_prefixed_keys() -> None:
+ """Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) —
+ Птица их сегодня не использует, но одинаковый механизм на оба продукта
+ проще сопровождать (#2457-review)."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {
+ "extra": {
+ "client_name": "Иван",
+ "client_phone": "+79991234567",
+ "client_email": "ivan@example.com",
+ }
+ }
+ result = scrub_pii_event(event, {})
+ extra = result["extra"]
+ assert extra["client_name"] == "[REDACTED]"
+ assert extra["client_phone"] == "[REDACTED]"
+ assert extra["client_email"] == "[REDACTED]"
+
+
+def test_scrub_pii_redacts_extra() -> None:
+ """scrub_pii_event заменяет PII-ключи в extra, не трогая остальное."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {
+ "extra": {
+ "phone": "+79990000000",
+ "email": "x@y.ru",
+ "name": "Пётр",
+ "lead_id": 42,
+ }
+ }
+ result = scrub_pii_event(event, {})
+ extra = result["extra"]
+ assert extra["phone"] == "[REDACTED]"
+ assert extra["email"] == "[REDACTED]"
+ assert extra["name"] == "[REDACTED]"
+ assert extra["lead_id"] == 42
+
+
+def test_scrub_pii_is_case_insensitive() -> None:
+ """Сверка PII-ключей case-insensitive."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}}
+ result = scrub_pii_event(event, {})
+ assert result["extra"]["Client_Name"] == "[REDACTED]"
+ assert result["extra"]["PHONE"] == "[REDACTED]"
+
+
+def test_scrub_pii_redacts_nested_contexts() -> None:
+ """scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}}
+ result = scrub_pii_event(event, {})
+ assert result["contexts"]["lead"]["email"] == "[REDACTED]"
+ assert result["contexts"]["trace"]["op"] == "http"
+
+
+def test_scrub_pii_leaves_non_pii_untouched() -> None:
+ """scrub_pii_event не трогает event без PII-ключей."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {
+ "request": {"data": {"region": "66", "area_sqm": 50}},
+ "extra": {"job": "geocode"},
+ "level": "error",
+ }
+ result = scrub_pii_event(event, {})
+ assert result["request"]["data"] == {"region": "66", "area_sqm": 50}
+ assert result["extra"] == {"job": "geocode"}
+ assert result["level"] == "error"
+
+
+def test_scrub_pii_handles_missing_sections() -> None:
+ """scrub_pii_event не падает на пустом event."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ result = scrub_pii_event({}, {})
+ assert result == {}
+
+
+def test_scrub_pii_handles_none_and_non_dict_sections() -> None:
+ """scrub_pii_event не бросает исключений на None/не-dict секциях."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"}
+ result = scrub_pii_event(event, {})
+ assert result is event
+
+
+def test_scrub_pii_returns_event_not_none() -> None:
+ """before_send должен вернуть event (не None) — иначе SDK дропнет отчёт."""
+ from app.observability.sentry_scrub import scrub_pii_event
+
+ event: dict = {"request": {"data": {"client_name": "X"}}}
+ result = scrub_pii_event(event, {})
+ assert result is not None
+ assert result is event
+
+
+# ── scrub_event (composed before_send / before_send_transaction handler) ───────
+#
+# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА
+# канала (before_send И before_send_transaction). До #2457-review composed-хук
+# висел только на before_send, а before_send_transaction оставался на голом
+# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data
+# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь
+# через transaction-канал при glitchtip_traces_sample_rate > 0.
+
+
+def test_scrub_event_composes_pii_and_url_secret_scrub() -> None:
+ """scrub_event применяет PII-scrub (ключ-based) И URL query-string secret
+ redact (regex) оба разом, не заменяя друг друга — разные классы данных."""
+ from app.observability.sentry_scrub import scrub_event
+
+ event: dict = {
+ "request": {
+ "data": {"client_name": "Иван"},
+ "url": "https://example.com?api_key=supersecret",
+ }
+ }
+ result = scrub_event(event, {})
+ assert result is not None
+ assert result["request"]["data"]["client_name"] == "[REDACTED]"
+ assert "[REDACTED]" in result["request"]["url"]
+ assert "supersecret" not in result["request"]["url"]
+
+
+def test_scrub_event_returns_event_not_none() -> None:
+ """scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт."""
+ from app.observability.sentry_scrub import scrub_event
+
+ event: dict = {"request": {"data": {"name": "X"}}}
+ result = scrub_event(event, {})
+ assert result is not None
+ assert result is event
+
+
+def test_scrub_event_survives_scrub_pii_event_exception() -> None:
+ """try/except в scrub_event — предохранитель: sentry_sdk оборачивает
+ before_send в capture_internal_exceptions, который при исключении ТОЛЬКО
+ логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал).
+ Если scrub_pii_event падает — scrub_event обязан вернуть event, а не
+ пробросить исключение дальше (#2457-review)."""
+ from app.observability.sentry_scrub import scrub_event
+
+ event: dict = {"request": {"data": {"client_name": "X"}}}
+ with patch(
+ "app.observability.sentry_scrub.scrub_pii_event",
+ side_effect=RuntimeError("boom"),
+ ):
+ result = scrub_event(event, {})
+ assert result is not None
+ assert result is event
+
+
+def test_scrub_event_survives_scrub_sensitive_query_exception() -> None:
+ """То же самое для второго шага композиции (URL-secret redact)."""
+ from app.observability.sentry_scrub import scrub_event
+
+ event: dict = {"request": {"data": {"name": "X"}}}
+ with patch(
+ "app.observability.sentry_scrub.scrub_sensitive_query",
+ side_effect=RuntimeError("boom"),
+ ):
+ result = scrub_event(event, {})
+ assert result is not None
+ assert result is event
+
+
+# ── wiring: before_send/before_send_transaction реально используют scrub_event ──
+#
+# Source-grep вместо мока sentry_sdk.init: main.py/celery_app.py вызывают
+# sentry_sdk.init() на module-level import, поэтому мок пришлось бы ставить ДО
+# импорта app.main — фрагильно и не переиспользуемо между тестами (модуль уже
+# закэширован в sys.modules к моменту первого теста). Прямая проверка исходника
+# — детерминированный, дешёвый и точный регрессионный гейт на саму строку,
+# которую правил review (#2457).
+
+
+def test_main_wires_scrub_event_to_both_channels() -> None:
+ """app/main.py: before_send И before_send_transaction ОБА на scrub_event."""
+ text = (_BACKEND_ROOT / "app" / "main.py").read_text(encoding="utf-8")
+ assert "before_send=scrub_event" in text
+ assert "before_send_transaction=scrub_event" in text
+
+
+def test_celery_app_wires_scrub_event_to_both_channels() -> None:
+ """app/workers/celery_app.py: before_send И before_send_transaction ОБА на
+ scrub_event (раньше before_send не было вообще)."""
+ text = (_BACKEND_ROOT / "app" / "workers" / "celery_app.py").read_text(encoding="utf-8")
+ assert "before_send=scrub_event" in text
+ assert "before_send_transaction=scrub_event" in text
From 5ff06d25b46c70f82fc9370d814417da112ab726 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 21:48:06 +0300
Subject: [PATCH 003/124] =?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'
"
+ 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 76c3f5c110f329b7e4e60d8480ca39fcefeb8dad Mon Sep 17 00:00:00 2001
From: lekss361
Date: Thu, 6 Aug 2026 18:59:50 +0000
Subject: [PATCH 004/124] =?UTF-8?q?chore(frontend):=20=D1=84=D0=B8=D0=BA?=
=?UTF-8?q?=D1=81=D1=82=D1=83=D1=80=D1=8B=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82?=
=?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB?=
=?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20+=20=D1=83=D0=B4=D0=B0=D0=BB?=
=?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=81=D0=B8=D1=80=D0=BE=D1=82?=
=?UTF-8?q?=D0=B5=D0=B2=D1=88=D0=B8=D1=85=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?=
=?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20(#2747)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
МЕРА: у 8 компонентов витрины v2 проп data больше не имеет дефолта из fixtures.ts — при сбое передачи данных компонент обязан упасть на TS-ошибке, а не отрисовать выдуманные числа на платном экране оценки. Цепная правка в SectionOverlay (4 поля стали обязательными в такт с детьми).
Птица: удалены 6 осиротевших компонентов (ноль импортов подтверждён репо-wide), подчищены 2 ссылающихся комментария.
Проверено ревьюером: tsc --noEmit и next lint реально отработали на 91b460b1 (лог задачи 18031), vitest 32/264 зелёные (лог 18033); storybook в репозитории отсутствует вовсе — «unwired/storybook usage» как обоснование дефолтов никогда не имело потребителя; ui-preview/estimate использует v1-компоненты со своей локальной фикстурой и не задет.
---
.../src/components/site-finder/ScoreCard.tsx | 5 -
.../analysis/AnalysisBreadcrumb.tsx | 82 ----
.../site-finder/analysis/AnalysisSidebar.tsx | 266 ------------
.../site-finder/analysis/MassingEconomics.tsx | 410 ------------------
.../site-finder/analysis/UserAvatar.tsx | 104 -----
.../ptica/PticaPlaceholderPanel.tsx | 22 -
frontend/src/lib/concept-api.ts | 2 +-
frontend/src/lib/nspdLinks.ts | 2 +-
.../components/trade-in/v2/AnalyticsView.tsx | 14 +-
.../src/components/trade-in/v2/CacheView.tsx | 14 +-
.../src/components/trade-in/v2/Footer.tsx | 11 +-
.../src/components/trade-in/v2/HeroBar.tsx | 11 +-
.../components/trade-in/v2/HistoryView.tsx | 17 +-
.../components/trade-in/v2/ObjectSummary.tsx | 16 +-
.../components/trade-in/v2/ResultPanel.tsx | 23 +-
.../components/trade-in/v2/SectionOverlay.tsx | 13 +-
.../components/trade-in/v2/SourcesView.tsx | 27 +-
17 files changed, 66 insertions(+), 973 deletions(-)
delete mode 100644 frontend/src/components/site-finder/ScoreCard.tsx
delete mode 100644 frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx
delete mode 100644 frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx
delete mode 100644 frontend/src/components/site-finder/analysis/MassingEconomics.tsx
delete mode 100644 frontend/src/components/site-finder/analysis/UserAvatar.tsx
delete mode 100644 frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx
diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx
deleted file mode 100644
index 2229b1f1..00000000
--- a/frontend/src/components/site-finder/ScoreCard.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-// ScoreCard is superseded by the tabbed dashboard in page.tsx.
-// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab.
-// File kept to avoid breaking any external imports; exports an empty stub.
-
-export {};
diff --git a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx
deleted file mode 100644
index b72e33c9..00000000
--- a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { ChevronRight } from "lucide-react";
-
-interface AnalysisBreadcrumbProps {
- cadNum: string;
-}
-
-export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) {
- return (
-
- );
-}
diff --git a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx
deleted file mode 100644
index 487f5cd6..00000000
--- a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx
+++ /dev/null
@@ -1,266 +0,0 @@
-"use client";
-
-import React, { useEffect, useRef, useState } from "react";
-import { ExternalLink } from "lucide-react";
-
-// ── Types ─────────────────────────────────────────────────────────────────────
-
-interface SubSection {
- id: string;
- label: string;
-}
-
-interface NavSection {
- id: string;
- label: string;
- sub?: SubSection[];
-}
-
-// ── Config ────────────────────────────────────────────────────────────────────
-
-const NAV_SECTIONS: NavSection[] = [
- { id: "section-1", label: "1. Объект" },
- { id: "section-2", label: "2. Земля и риски" },
- {
- id: "section-3",
- label: "3. Рынок",
- sub: [
- { id: "section-3-1", label: "3.1 Настройки выборки" },
- { id: "section-3-2", label: "3.2 Планировки" },
- { id: "section-3-3", label: "3.3 Остатки и скорость" },
- ],
- },
- { id: "section-4", label: "4. Оценка" },
- { id: "section-5", label: "5. Атмосфера" },
- {
- id: "section-6",
- label: "6. Прогноз",
- sub: [
- { id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
- { id: "section-6-2", label: "6.2 Сценарии" },
- { id: "section-6-3", label: "6.3 Уверенность" },
- { id: "section-6-4", label: "6.4 Рекомендация по продукту" },
- { id: "section-6-5", label: "6.5 Прозрачность скоринга" },
- { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
- ],
- },
-];
-
-// All section IDs in scroll order (for IntersectionObserver)
-const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) =>
- s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id],
-);
-
-// ── Component ─────────────────────────────────────────────────────────────────
-
-export function AnalysisSidebar() {
- const [activeId, setActiveId] = useState(ALL_SECTION_IDS[0]);
- const observerRef = useRef(null);
-
- // Scrollspy via IntersectionObserver
- useEffect(() => {
- const candidates = ALL_SECTION_IDS.map((id) =>
- document.getElementById(id),
- ).filter((el): el is HTMLElement => el !== null);
-
- if (candidates.length === 0) return;
-
- // Track which sections are visible; pick topmost visible one
- const visible = new Set();
-
- observerRef.current = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) {
- visible.add(entry.target.id);
- } else {
- visible.delete(entry.target.id);
- }
- });
-
- // Pick the topmost section that is currently visible
- const next = ALL_SECTION_IDS.find((id) => visible.has(id));
- if (next) setActiveId(next);
- },
- {
- root: null,
- // Trigger when section top enters top 60% of viewport
- rootMargin: "-8px 0px -40% 0px",
- threshold: 0,
- },
- );
-
- candidates.forEach((el) => observerRef.current!.observe(el));
-
- return () => {
- observerRef.current?.disconnect();
- };
- }, []);
-
- function handleAnchorClick(
- e: React.MouseEvent,
- targetId: string,
- ) {
- e.preventDefault();
- const el = document.getElementById(targetId);
- if (el) {
- el.scrollIntoView({ behavior: "smooth", block: "start" });
- }
- setActiveId(targetId);
- }
-
- return (
-
- );
-}
diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx
deleted file mode 100644
index 272c6239..00000000
--- a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx
+++ /dev/null
@@ -1,410 +0,0 @@
-"use client";
-
-/**
- * MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b,
- * epic #1953).
- *
- * Driven by the interactive 3D MassingScene: every time the user drags the
- * этажность / секций sliders, Section7Concept maps the scene's `computeModel`
- * result + the analysis context into a `MassingProgram` and hands it here via
- * `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and
- * render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость /
- * прибыль / ROI).
- *
- * Robustness:
- * • debounce — slider drags fire many programs; only the settled one is sent.
- * • latest-wins — an in-flight request is superseded by a newer one via a
- * monotonic request id; a stale response is dropped, never overwriting a
- * fresher result (mutateAsync + id guard, no UI flicker from out-of-order).
- * • last-good — on a failed recompute we keep the last successful values and
- * show a subtle inline note rather than blanking the panel.
- * • skeleton — a plain grey fade KPI grid while the FIRST recompute is in
- * flight (no shimmer, per ui-conventions).
- *
- * Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but
- * this strip uses the light KPI tokens via the shared KpiCard.
- */
-
-import { useEffect, useRef, useState } from "react";
-import { AlertTriangle } from "lucide-react";
-
-import { KpiCard } from "@/components/analytics/KpiCard";
-import { Section } from "@/components/analytics/Section";
-import {
- priceSourceCaption,
- useRecomputeMassing,
- type FinancialModel,
- type MassingProgram,
- type MassingRecomputeOutput,
- type Teap,
-} from "@/lib/concept-api";
-
-const DEBOUNCE_MS = 250;
-
-// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ─────────
-
-const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
-
-/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
-function formatMoneyCompact(rub: number): string {
- const abs = Math.abs(rub);
- if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
- if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
- return `${nf.format(Math.round(rub))} ₽`;
-}
-
-function formatInt(n: number): string {
- return nf.format(Math.round(n));
-}
-
-function formatPct(fraction: number): string {
- return `${(fraction * 100).toFixed(1)}%`;
-}
-
-function formatFar(far: number): string {
- return far.toLocaleString("ru-RU", {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- });
-}
-
-// ── KPI grid ───────────────────────────────────────────────────────────────────
-
-interface KpiGridProps {
- teap: Teap;
- financial: FinancialModel;
- /** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */
- farTarget: number;
- /** True → факт-КСИТ превышает регламентный потолок (model.over). */
- ksitOver: boolean;
- /** Dim the strip while a fresher recompute is in flight (last-good values). */
- stale: boolean;
-}
-
-function KpiGrid({
- teap,
- financial,
- farTarget,
- ksitOver,
- stale,
-}: KpiGridProps) {
- const netPositive =
- financial.net_profit_rub > 0
- ? true
- : financial.net_profit_rub < 0
- ? false
- : null;
-
- return (
-
- );
-}
-
-// ── Component ───────────────────────────────────────────────────────────────────
-
-interface Props {
- /**
- * The current massing program (Σ footprint × floors + context), mapped by
- * Section7Concept from the 3D scene's computeModel result. `null` until the
- * scene has fired its first onModelChange (or when geometry is missing).
- */
- program: MassingProgram | null;
- /** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
- farTarget: number;
- /** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
- ksitOver: boolean;
- /**
- * True when the parcel is regulatorily constrained for МКД (gate-blocked /
- * non-residential / ЗОУИТ-СЗЗ) — drives the honest «условный расчёт» caveat
- * below the strip alongside the negative-economics case.
- */
- gateConstrained?: boolean;
-}
-
-export function MassingEconomics({
- program,
- farTarget,
- ksitOver,
- gateConstrained = false,
-}: Props) {
- const recompute = useRecomputeMassing();
-
- // Last successful result kept locally so a failed/stale recompute never blanks
- // the panel (last-good values stay on screen).
- const [result, setResult] = useState(null);
- const [errored, setErrored] = useState(false);
-
- // Monotonic request id → latest-wins: a response is applied only if it belongs
- // to the most recently issued request, so out-of-order arrivals are dropped.
- const reqIdRef = useRef(0);
- const debounceRef = useRef | null>(null);
-
- // Stable JSON key so we only recompute when the program actually changes
- // (not on every parent re-render that hands an equal-but-new object).
- const programKey = program ? JSON.stringify(program) : null;
-
- useEffect(() => {
- if (!program) return;
-
- if (debounceRef.current) clearTimeout(debounceRef.current);
- debounceRef.current = setTimeout(() => {
- const id = ++reqIdRef.current;
- recompute
- .mutateAsync(program)
- .then((out) => {
- if (id !== reqIdRef.current) return; // stale — a newer request won.
- setResult(out);
- setErrored(false);
- })
- .catch(() => {
- if (id !== reqIdRef.current) return; // stale failure — ignore.
- setErrored(true); // keep last-good `result`.
- });
- }, DEBOUNCE_MS);
-
- return () => {
- if (debounceRef.current) clearTimeout(debounceRef.current);
- };
- // recompute is a stable mutation object; we key off the serialized program.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [programKey]);
-
- // No program yet → nothing to show (parent gates this on geometry anyway).
- if (!program) return null;
-
- // First recompute in flight, no last-good value yet → skeleton.
- if (!result) {
- return (
-
-
-
- );
- }
-
- // A fresher request is in flight over the last-good values.
- const stale = recompute.isPending;
-
- // Honest «условный расчёт» caveat: when the economics turn negative OR the
- // parcel is regulatorily constrained (gate-blocked / ЗОУИТ-СЗЗ / нежилое),
- // we say so plainly rather than presenting the figures as a viable project.
- const economicsNegative =
- result.financial.net_profit_rub < 0 || result.financial.npv_rub < 0;
- const showConditionalNote = economicsNegative || gateConstrained;
-
- return (
-
-
-
- {showConditionalNote ? (
-
-
-
- Расчёт условный: участок ограничен регламентом (см. блокеры выше)
- и/или экономика отрицательна при текущих вводных. Измените
- этажность, число секций или класс — модель пересчитается.
-
-
- ) : null}
-
- {errored ? (
-
-
- Не удалось пересчитать экономику по последнему изменению — показаны
- предыдущие значения. Измените параметры ещё раз для повторного
- расчёта.
-
- );
-}
diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts
index a3e4ff60..e16a5fe0 100644
--- a/frontend/src/lib/concept-api.ts
+++ b/frontend/src/lib/concept-api.ts
@@ -330,7 +330,7 @@ export interface MassingRecomputeOutput {
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
*
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
- * with latest-wins sequencing handled by the caller (see MassingEconomics).
+ * with latest-wins sequencing handled by the caller.
*/
export function useRecomputeMassing() {
return useMutation({
diff --git a/frontend/src/lib/nspdLinks.ts b/frontend/src/lib/nspdLinks.ts
index 773ada1a..7e7918d2 100644
--- a/frontend/src/lib/nspdLinks.ts
+++ b/frontend/src/lib/nspdLinks.ts
@@ -7,7 +7,7 @@
* и иметь один безопасный канал кодирования кадастрового номера.
*
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
- * NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
+ * NspdOpportunityBlock, ParcelDrawer.
*/
/**
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
index ae5e824b..9092eeee 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
@@ -1,18 +1,20 @@
"use client";
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
-// Accepts one composite `data` prop (default = the design fixture); page.tsx
-// feeds mapAnalytics(...) output. The price-history dots navigate to the
-// "Аналогичные объявления" overlay via onNavigate?.(2).
+// Accepts one composite `data` prop; page.tsx feeds mapAnalytics(...) output.
+// The price-history dots navigate to the "Аналогичные объявления" overlay via
+// onNavigate?.(2).
import { useMemo, useState } from "react";
import { tokens } from "./tokens";
-import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps {
- data?: Analytics;
+ // Required on the app path (v2/page.tsx -> SectionOverlay always supplies
+ // mapAnalytics output) — an omitted prop must be a TS error, not a silent
+ // fallback to fabricated house-analytics numbers.
+ data: Analytics;
onNavigate?: (i: number) => void;
}
@@ -130,7 +132,7 @@ function sellTimeTierValid(tier: SellTimeTier): boolean {
}
export default function AnalyticsView({
- data = ANALYTICS_FIXTURE,
+ data,
onNavigate,
}: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false);
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx
index 92158c73..1c699b66 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx
@@ -1,13 +1,10 @@
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
// Faithful markup port of МЕРА Оценка.dc.html lines 593-613.
-// Data via `data` prop (mapCache output); defaults to fixtures (cacheKpi + cacheRows).
+// Data via the required `data` prop (mapCache output).
import { tokens } from "./tokens";
-import { cacheKpi, cacheRows } from "./fixtures";
import type { CacheData } from "./mappers";
-const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
-
/**
* M8 — ВРЕМЯ column normalizer. `r.time` arrives PRE-FORMATTED as a string from
* mapCache (CacheRow.time, produced by fmtCacheTime), one of:
@@ -34,13 +31,16 @@ function normTime(raw: string | null | undefined): string {
}
interface CacheViewProps {
- data?: CacheData;
+ // Required on the app path (v2/page.tsx -> SectionOverlay always supplies
+ // mapCache output) — an omitted prop must be a TS error, not a silent
+ // fallback to fabricated «Предыдущие оценки» rows.
+ data: CacheData;
/** Row-click handler (#2420) — navigates to the full report for that
- * historical estimate. Optional: unwired/storybook usage leaves rows inert. */
+ * historical estimate. Optional: leaves rows inert when absent. */
onSelectRow?: (id: string) => void;
}
-export function CacheView({ data = FIXTURE_CACHE, onSelectRow }: CacheViewProps) {
+export function CacheView({ data, onSelectRow }: CacheViewProps) {
const showSrc = data.rows.some((r) => r.src && r.src !== "—");
// #2420 — ПАРАМЕТРЫ column inserted right after АДРЕС.
const gridCols = showSrc
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 cf79a55e..01ab9d22 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx
@@ -1,20 +1,23 @@
// 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, data from fixtures.
+// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`.
import { tokens } from "./tokens";
-import { report, version } from "./fixtures";
+import { version } from "./fixtures";
import type { Report } from "./types";
interface FooterProps {
- data?: Report;
+ // Required on the app path (v2/page.tsx always supplies mapReport output or
+ // EMPTY_REPORT, never the design fixtures) — an omitted prop must be a TS
+ // error, not a silent fallback to a fabricated report id/date.
+ data: Report;
// M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
// (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
hasEstimate?: boolean;
}
-export function Footer({ data = report, hasEstimate }: FooterProps) {
+export function Footer({ data, hasEstimate }: FooterProps) {
return (
SectionOverlay always supplies
+ // mapHistory output) — an omitted prop must be a TS error, not a silent
+ // fallback to fabricated house-sale/deal rows.
+ data: HistoryData;
}
-export default function HistoryView({
- data = HISTORY_FIXTURE,
-}: HistoryViewProps) {
+export default function HistoryView({ data }: HistoryViewProps) {
const showAsk = data.dkpRows.some((r) => r.ask && r.ask !== "—");
const dkpGridCols = showAsk
? "2.2fr 1fr 1.3fr 1fr .9fr 2fr"
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
index bcdffa01..04a8c13b 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
@@ -1,20 +1,20 @@
// 03 СВОДКА ОБЪЕКТА — right-rail summary card for the /trade-in/v2 "МЕРА Оценка"
// design port. Faithful markup port of МЕРА Оценка.dc.html lines 397-422:
// address + "В РАСЧЁТЕ" badge, four clickable per-section totals, and a
-// "КАЧЕСТВО ДАННЫХ" block with a donut. Data from fixtures; clicks fire
-// onNavigate(row.nav) only — no API, no fetching.
+// "КАЧЕСТВО ДАННЫХ" block with a donut. Data arrives via the `data` prop
+// (mapObject/mapSummary output); clicks fire onNavigate(row.nav) only — no
+// API, no fetching in this component itself.
"use client";
import { tokens } from "./tokens";
-import { object, summary } from "./fixtures";
import type { ObjectSummaryData } from "./mappers";
-// Default presentation data (unwired usage): the existing design fixtures.
-const OBJECT_SUMMARY_FIXTURE: ObjectSummaryData = { object, summary };
-
interface ObjectSummaryProps {
- data?: ObjectSummaryData;
+ // Required on the app path (v2/page.tsx always supplies mapObject/mapSummary
+ // output) — an omitted prop must be a TS error, not a silent fallback to
+ // fabricated per-section totals.
+ data: ObjectSummaryData;
onNavigate: (i: number) => void;
}
@@ -38,7 +38,7 @@ function rowHint(label: string): string {
}
export function ObjectSummary({
- data = OBJECT_SUMMARY_FIXTURE,
+ data,
onNavigate,
}: ObjectSummaryProps) {
return (
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
index 6bb5bc15..e2d50e8d 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
@@ -3,13 +3,6 @@
import { Fragment } from "react";
import type { Ref } from "react";
import { tokens } from "./tokens";
-import {
- ranges,
- resultCards,
- resultMeta,
- scatterMini,
- sources,
-} from "./fixtures";
import type { ResultPanelData } from "./mappers";
const {
@@ -39,17 +32,11 @@ const {
font,
} = tokens;
-// Default presentation data (unwired usage): the existing design fixtures.
-const RESULT_FIXTURE: ResultPanelData = {
- cards: resultCards,
- meta: resultMeta,
- ranges,
- scatterMini,
- sources,
-};
-
interface ResultPanelProps {
- data?: ResultPanelData;
+ // Required on the app path (v2/page.tsx always supplies mapResultPanel
+ // output) — an omitted prop must be a TS error, not a silent fallback to
+ // fabricated price/analytics numbers on a paid estimate screen.
+ data: ResultPanelData;
onNavigate: (i: number) => void;
// #2264 C7: after a successful estimate the page moves focus here (a labelled,
// programmatically-focusable region) so keyboard/SR users land on the result
@@ -92,7 +79,7 @@ function sourceName(name: string) {
}
export default function ResultPanel({
- data = RESULT_FIXTURE,
+ data,
onNavigate,
regionRef,
}: ResultPanelProps) {
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx
index 8a995065..b4ecdc72 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx
@@ -23,12 +23,13 @@ interface SectionOverlayProps {
onClose: () => void;
onNavigate: (i: number) => void;
// Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics
- // / mapCache). Optional so unwired/storybook usage falls back to each view's
- // fixture default.
- history?: HistoryData;
- sources?: SourcesData;
- analytics?: Analytics;
- cache?: CacheData;
+ // / mapCache) — always computed unconditionally (never undefined), and
+ // required here in lockstep with the child views' own required `data` prop
+ // (no more silent fallback to design fixtures on a missing value).
+ history: HistoryData;
+ sources: SourcesData;
+ analytics: Analytics;
+ cache: CacheData;
// Raw current estimate — threaded straight to SourcesView's SourcesMap
// (real per-lot lat/lon; sources/SourcesData above is pre-mapped display
// strings with no geometry). Optional/null: no estimate yet -> map degrades
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
index 7d1dc26d..fb9ceffc 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
@@ -4,7 +4,6 @@ import type { CSSProperties, ReactNode } from "react";
import { safeUrl } from "@/lib/safeUrl";
import type { AggregatedEstimate } from "@/types/trade-in";
import { tokens } from "./tokens";
-import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
import { SourcesMap } from "./SourcesMap";
@@ -12,9 +11,9 @@ import { SourcesMap } from "./SourcesMap";
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
// Card B: фактические сделки (3 KPI + dealRows table).
-// Markup is data-driven via the `data` prop; the design fixtures stay as the
-// DEFAULT for unwired/storybook usage. (M11 below may hide an all-«—» column,
-// so the storybook render can differ from the design HTML by a dead column.)
+// Markup is data-driven via the required `data` prop (mapSources output).
+// (M11 below may hide an all-«—» column, so the render can differ from the
+// design HTML by a dead column.)
//
// M7 — the two "tables" are CSS grids of s. They now carry real table
// semantics via ARIA roles (table / row / columnheader / cell) layered onto the
@@ -485,26 +484,20 @@ function DataTable({
);
}
-// Default presentation data (unwired/storybook usage): the existing design
-// fixtures. AdRow[]/DealRow[] satisfy AdRowData[]/DealRowData[] (extras optional).
-const SOURCES_FIXTURE: SourcesData = {
- adRows,
- dealRows,
- marketAds,
- marketDeals,
-};
-
interface SourcesViewProps {
- data?: SourcesData;
+ // Required on the app path (v2/page.tsx -> SectionOverlay always supplies
+ // mapSources output) — an omitted prop must be a TS error, not a silent
+ // fallback to fabricated listing/deal rows.
+ data: SourcesData;
// Raw estimate (real lat/lon per lot) — the table rows above are pre-mapped
// display strings with no geometry, so SourcesMap needs the source object
- // directly. Optional: unwired/storybook usage (no estimate) renders the
- // tables only, same honest-empty contract as the rest of the mappers.
+ // directly. Optional: no estimate yet renders the tables only, same honest-
+ // empty contract as the rest of the mappers.
estimate?: AggregatedEstimate | null;
}
export default function SourcesView({
- data = SOURCES_FIXTURE,
+ data,
estimate = null,
}: SourcesViewProps) {
const adCols = visibleCols(AD_COLS, data.adRows);
From 48664dfe0e7929be804260b4775b6c915cc0d1bb Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 22:49:09 +0300
Subject: [PATCH 005/124] =?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 79f7b8fff3bd71e84bc4646ef1673179aa20e232 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 20:53:55 +0000
Subject: [PATCH 006/124] =?UTF-8?q?ci(tradein):=20=D1=81=D0=BD=D1=8F=D1=82?=
=?UTF-8?q?=D1=8C=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D0=B9?=
=?UTF-8?q?=20deselect=20=E2=80=94=20=D0=BE=D0=BD=20=D0=BF=D0=B5=D1=80?=
=?UTF-8?q?=D0=B5=D0=B6=D0=B8=D0=BB=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=BA?=
=?UTF-8?q?=D1=83=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B3=D0=BE=20=D1=82=D0=B5?=
=?UTF-8?q?=D1=81=D1=82=D0=B0=20(#2756)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.forgejo/workflows/deploy-tradein.yml | 28 ++++++++++++++++++---------
1 file changed, 19 insertions(+), 9 deletions(-)
diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml
index 102dfde0..b93af07c 100644
--- a/.forgejo/workflows/deploy-tradein.yml
+++ b/.forgejo/workflows/deploy-tradein.yml
@@ -164,15 +164,25 @@ jobs:
run: uv sync --frozen
- name: Run pytest (tradein-mvp/backend)
- # DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
- # ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
- # leak из другого test-модуля, pre-existing. Второй исторический deselect
- # (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
- # прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
- # совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
- run: |
- uv run pytest -q \
- --deselect "tests/test_search_api.py::test_search_cache_hit"
+ # БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml.
+ #
+ # Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с
+ # объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
+ # global-state leak из другого модуля». Объяснение было неверным в обеих
+ # половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в
+ # /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на
+ # такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в
+ # pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё
+ # на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт.
+ #
+ # Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс
+ # дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
+ # помечается xfail с причиной В КОДЕ, рядом с самим тестом.
+ #
+ # `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне —
+ # 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый
+ # пропуск роняет прогон через хук в tests/conftest.py.
+ run: uv run pytest -q -rs
build-backend:
runs-on: ubuntu-latest
From b1498a9ca762c52de59c99d37c3dd50a9b967ace Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 21:24:57 +0000
Subject: [PATCH 007/124] =?UTF-8?q?fix(tradein/devops):=20=D0=BB=D0=BE?=
=?UTF-8?q?=D0=B3=D0=B8=20=D0=B2=20journald=20=E2=80=94=20=D0=BF=D0=B5?=
=?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0=D1=8E=D1=82=20=D0=BF=D0=B5?=
=?UTF-8?q?=D1=80=D0=B5=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20?=
=?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B0=20?=
=?UTF-8?q?(#2741)=20(#2758)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tradein-mvp/docker-compose.prod.yml | 44 +++++++++++++++++++++++++----
1 file changed, 39 insertions(+), 5 deletions(-)
diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml
index bb1e073d..23274958 100644
--- a/tradein-mvp/docker-compose.prod.yml
+++ b/tradein-mvp/docker-compose.prod.yml
@@ -23,13 +23,47 @@
# ПЕРЕСОЗДАСТ ВСЕ tradein-контейнеры, включая scraper — прервёт бегущий sweep
# (stop_grace_period 120s даёт unit'у до-checkpoint'иться). Допустимо, разово.
#
-# logging: json-file с ротацией (20m × 3 = ≤60M на сервис). До этого драйвер по
-# умолчанию рос без границ. Общий anchor ниже.
+# logging: journald (#2741). Было json-file 20m × 3 — оно решало только размер,
+# и ценой того, что лог ЖИВЁТ В КОНТЕЙНЕРЕ: `docker rm` уносит его целиком.
+# Деплоев ~20/сутки, скрейперы работают ночью, разбор идёт утром → окно жизни
+# лога почти никогда не покрывает интересное (#2695, #2698, #2676 — три разбора
+# подряд уперлись в «логов уже нет»).
+#
+# journald: демон отдаёт stdout/stderr в /var/log/journal (persistent, на хосте),
+# запись переживает пересоздание контейнера и читается ПО ВРЕМЕНИ, а не «сколько
+# осталось от последнего рестарта».
+#
+# КАК ЧИТАТЬ (проверено на проде 2026-08-06):
+# docker logs tradein-scraper # как и раньше: только текущий контейнер
+# # история через пересоздания — журнал принадлежит root, а deploy-юзер
+# # gendesign состоит в docker, но НЕ в adm/systemd-journal, и sudo просит пароль,
+# # поэтому голый `journalctl` у него выдаёт «No entries». Рабочий однострочник:
+# docker run --rm -v /:/host:ro alpine chroot /host \
+# journalctl -t tradein-scraper --utc --since "2026-08-07 00:30" --until "01:30"
+# # то же по метке контейнера: CONTAINER_NAME=tradein-scraper (-o cat -f для tail -f)
+# Владельцу стоит разово выдать `sudo usermod -aG adm gendesign` — после этого
+# journalctl работает напрямую, без docker-обёртки (host-config, не этот файл).
+#
+# Ротация: журнал общий на хост, режется самим journald по размеру (SystemMaxUse)
+# и НЕ обнуляется пересозданием контейнера — то есть глубина истории меряется
+# сутками, а не «сколько прошло с последнего деплоя» (это и был баг #2741/#2715:
+# 60 МБ json-file при флуде прокручивались за минуты, а деплой обнулял и их).
+# Жёсткий потолок по возрасту (MaxRetentionSec) — при желании host drop-in.
+# Замер 2026-08-06: /var/log/journal =
+# 2.3G за 103 дня (~22 МБ/сутки системных) при дефолтном потолке SystemMaxUse=4G;
+# tradein добавляет единицы-десятки МБ/сутки (5.8k карточек за сутки по
+# scrape_runs, ~153 Б/строку) → до вытеснения старого ещё недели, требуемые
+# «сутки-двое» покрыты с запасом. Дисковый риск нулевой: 4G — это ПОТОЛОК, при
+# его достижении journald сам удаляет старейшее.
+# Ceiling: journald рейт-лимитит (дефолт 10000 сообщений / 30s на сервис) — при
+# превышении в журнал попадёт «Suppressed N messages». Июльский флуд postgres
+# (40 МБ за 6 часов ≈ 12 строк/с) от лимита в ~25 раз ниже, но если такое
+# появится — это host-config (drop-in journald.conf.d), не этот файл.
+# tag: имя контейнера вместо ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
x-logging: &default-logging
- driver: json-file
+ driver: journald
options:
- max-size: "20m"
- max-file: "3"
+ tag: "{{.Name}}"
services:
browser:
From d605f0b938a413a537f9bbb315d5e8ce6827644d Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 21:40:05 +0000
Subject: [PATCH 008/124] =?UTF-8?q?ci:=20=D0=B3=D0=B5=D0=B9=D1=82=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?=
=?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82?=
=?UTF-8?q?=D1=8B=D1=85=20=D0=BD=D0=B0=20VPS=20=D0=BF=D0=BE=D1=80=D1=82?=
=?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=20workflow=20(#2757)=20(#2759)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.forgejo/workflows/ci.yml | 24 +++++-
scripts/check-workflow-ports.py | 125 ++++++++++++++++++++++++++++++++
2 files changed, 147 insertions(+), 2 deletions(-)
create mode 100644 scripts/check-workflow-ports.py
diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index 66d5fd7f..0719e2a3 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -12,8 +12,18 @@ name: CI
# единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через
# connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango
# установлен ниже), тогда как на macOS-dev они runtime-skip'аются.
-# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
-# .github/workflows/ci.yml как образец service-блока.
+#
+# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через
+# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис-
+# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ
+# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод.
+# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного
+# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект
+# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres
+# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример
+# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не
+# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions)
+# уронит сборку, если такая публикация всё же появится.
on:
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709).
# WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым
@@ -45,6 +55,16 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
+
+ - name: "Guard: host-port collisions in workflows (#2757)"
+ # Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на
+ # КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает
+ # дефицитный слот раннера. Падение = merge заблокирован.
+ # python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3).
+ run: |
+ python3 scripts/check-workflow-ports.py --selftest
+ python3 scripts/check-workflow-ports.py
+
- uses: dorny/paths-filter@v3
id: filter
with:
diff --git a/scripts/check-workflow-ports.py b/scripts/check-workflow-ports.py
new file mode 100644
index 00000000..887c1169
--- /dev/null
+++ b/scripts/check-workflow-ports.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757).
+
+ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host`
+(проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}`
+→ `host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём
+`services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо
+уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из
+job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля.
+
+Список занятых портов — не догадки, а замер `ss -ltnp` на bot-server 2026-08-06.
+Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ
+СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они
+живут в bridge-сетях) — сервис-контейнер с их портами ни с чем не столкнётся,
+и запрещать их значило бы ронять сборку на ровном месте.
+
+Правильный способ дать сборке БД — как в ci-tradein.yml: явный `docker run`
+в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера.
+
+Запуск: python3 scripts/check-workflow-ports.py [--selftest]
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+# host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06)
+RESERVED: dict[int, str] = {
+ 22: "sshd хоста",
+ 53: "systemd-resolved (127.0.0.53)",
+ 80: "gendesign-caddy-1 (0.0.0.0:80)",
+ 443: "gendesign-caddy-1 (0.0.0.0:443)",
+ 2222: "forgejo, git-over-ssh (0.0.0.0:2222)",
+ 3000: "gendesign-frontend-1 (127.0.0.1:3000)",
+ 5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)",
+ 8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)",
+}
+
+WORKFLOW_DIR = Path(".forgejo/workflows")
+
+# "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port)
+PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?")
+PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)")
+
+
+def scan(text: str) -> list[tuple[int, str, int]]:
+ """-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p."""
+ hits: list[tuple[int, str, int]] = []
+ in_ports = False
+ ports_indent = 0
+
+ for lineno, raw in enumerate(text.splitlines(), 1):
+ stripped = raw.strip()
+ if not stripped or stripped.startswith("#"):
+ continue # комментарий ничего не запускает (в них живут анти-примеры)
+ indent = len(raw) - len(raw.lstrip())
+
+ if in_ports and (not stripped.startswith("- ") or indent <= ports_indent):
+ in_ports = False
+
+ candidates: list[str] = []
+ if stripped.startswith("ports:"):
+ rest = stripped[len("ports:") :].strip()
+ if rest:
+ candidates.append(rest) # инлайн-форма: ports: ["5432:5432"]
+ else:
+ in_ports, ports_indent = True, indent
+ elif in_ports:
+ candidates.append(stripped)
+ candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish
+
+ for candidate in candidates:
+ for host_port, _container_port in PORT_PAIR.findall(candidate):
+ if int(host_port) in RESERVED:
+ hits.append((lineno, stripped, int(host_port)))
+ return hits
+
+
+def selftest() -> None:
+ assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)]
+ assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000
+ assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000
+ assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443
+ # безопасные формы и посторонние двоеточия — не должны ронять сборку
+ assert scan(" ports:\n - 5432\n") == []
+ assert scan(" ports:\n - 15432:5432\n") == []
+ assert scan(" image: postgis/postgis:16-3.4\n") == []
+ assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == []
+ assert scan(" # ports:\n # - 5432:5432\n") == []
+ assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == []
+ # выход из блока ports: по отступу
+ assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == []
+ print("selftest OK")
+
+
+def main() -> int:
+ if "--selftest" in sys.argv:
+ selftest()
+ return 0
+
+ if not WORKFLOW_DIR.is_dir():
+ print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория")
+ return 1
+
+ failed = False
+ for path in sorted(WORKFLOW_DIR.glob("*.yml")):
+ for lineno, line, port in scan(path.read_text(encoding="utf-8")):
+ failed = True
+ print(
+ f"::error file={path},line={lineno}::публикация порта {port} — он занят "
+ f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер "
+ f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай "
+ f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP "
+ f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}"
+ )
+ if failed:
+ return 1
+ print("✓ ни один workflow не публикует занятый на VPS порт")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 604ce14858df1efdfd7ba24f03cdd320eb53faf2 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 21:53:02 +0000
Subject: [PATCH 009/124] =?UTF-8?q?fix(tradein/devops):=20=D0=BF=D0=BE?=
=?UTF-8?q?=D1=87=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BE=D0=BA=D0=BD=D0=BE?=
=?UTF-8?q?=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B2=20=D0=B8?=
=?UTF-8?q?=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=86=D0=B8=D0=B8=20=D1=87?=
=?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?=
=?UTF-8?q?=D0=BB=D0=B0=20(#2741)=20(#2760)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tradein-mvp/docker-compose.prod.yml | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml
index 23274958..b7e602a8 100644
--- a/tradein-mvp/docker-compose.prod.yml
+++ b/tradein-mvp/docker-compose.prod.yml
@@ -38,9 +38,13 @@
# # история через пересоздания — журнал принадлежит root, а deploy-юзер
# # gendesign состоит в docker, но НЕ в adm/systemd-journal, и sudo просит пароль,
# # поэтому голый `journalctl` у него выдаёт «No entries». Рабочий однострочник:
-# docker run --rm -v /:/host:ro alpine chroot /host \
-# journalctl -t tradein-scraper --utc --since "2026-08-07 00:30" --until "01:30"
-# # то же по метке контейнера: CONTAINER_NAME=tradein-scraper (-o cat -f для tail -f)
+# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
+# 'TZ=UTC journalctl -t tradein-scraper -o short-iso \
+# --since "2026-08-07 00:39" --until "2026-08-07 01:40"'
+# # TZ=UTC — не украшение: `--since/--until` разбираются в ЛОКАЛЬНОМ времени хоста
+# # (а он в +03), и флаг `--utc` на это НЕ влияет — он меняет только вывод. Без
+# # TZ=UTC окно уезжает на 3 часа и молча отдаёт чужой кусок ночи. Проверено.
+# # То же по метке контейнера: CONTAINER_NAME=tradein-scraper (-o cat -f для tail -f)
# Владельцу стоит разово выдать `sudo usermod -aG adm gendesign` — после этого
# journalctl работает напрямую, без docker-обёртки (host-config, не этот файл).
#
From 896243cf3f0a84eca4ebe9290223a20aaf00ba49 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 22:29:27 +0000
Subject: [PATCH 010/124] =?UTF-8?q?fix(devops):=20=D0=BF=D0=BE=D1=82=D0=BE?=
=?UTF-8?q?=D0=BB=D0=BE=D0=BA=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2=20=D0=BE?=
=?UTF-8?q?=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D1=82?=
=?UTF-8?q?=D0=B5=D0=BA=D0=B0=20=E2=80=94=20journald,=20=D0=BA=D0=B0=D0=BA?=
=?UTF-8?q?=20=D1=83=20trade-in=20(#2761)=20(#2762)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docker-compose.prod.yml | 67 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 67 insertions(+)
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 768c2af9..38733e49 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -17,10 +17,66 @@
#
# Postgres + Redis run alongside the app on the same VM (Discovery mode).
# Volumes are shared with docker-compose.yml so switching between files preserves data.
+#
+# ── logging: journald (#2761) ────────────────────────────────────────────────
+# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ
+# и живёт в /var/lib/docker/containers// (умирает вместе с контейнером).
+# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН
+# способ на обе половины, расхождение двух стеков дороже в поддержке.
+#
+# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера):
+# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека
+# postgres 2.2 ← 162 МБ за 74 дня
+# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5
+# ИТОГО ~39 МБ/сутки
+# Бюджет journald (замер там же, сообщение самого systemd-journald):
+# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён
+# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G).
+# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки
+# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины.
+# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с
+# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс
+# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ.
+#
+# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть):
+# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер
+# # История через пересоздания: журнал принадлежит root:systemd-journal, а
+# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и
+# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт
+# # "No entries". Рабочий однострочник — через docker-группу:
+# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
+# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"'
+# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста
+# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760).
+# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID=
+# # — так читается лог УЖЕ УДАЛЁННОГО контейнера).
+# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl
+# заработает напрямую (host-config, не этот файл). До этого правка не регрессия.
+#
+# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так
+# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так
+# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки:
+# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка
+# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без
+# ночных прогонов.
+# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при
+# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker
+# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это
+# host drop-in journald.conf.d, не этот файл.
+# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
+#
+# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной
+# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры
+# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size.
+x-logging: &default-logging
+ driver: journald
+ options:
+ tag: "{{.Name}}"
services:
postgres:
image: postgis/postgis:16-3.4
+ logging: *default-logging
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
@@ -46,6 +102,7 @@ services:
redis:
image: redis:7-alpine
+ logging: *default-logging
restart: unless-stopped
volumes:
- redis_data:/data
@@ -76,6 +133,7 @@ services:
# отдельно (docs/osrm-routing.md).
osrm:
image: osrm/osrm-backend:latest
+ logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm
volumes:
@@ -103,6 +161,7 @@ services:
# (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000.
osrm-walk:
image: osrm/osrm-backend:latest
+ logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
volumes:
@@ -118,6 +177,7 @@ services:
backend:
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
+ logging: *default-logging
restart: unless-stopped
# .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG).
# required: false — compose не падает если файла нет (первый деплой).
@@ -153,6 +213,7 @@ services:
frontend:
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
+ logging: *default-logging
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
@@ -172,6 +233,7 @@ services:
worker:
# Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium.
image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest}
+ logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
@@ -201,6 +263,7 @@ services:
beat:
# Lean backend-образ (без Chromium) — beat только триггерит таски в Redis.
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
+ logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
@@ -219,6 +282,7 @@ services:
glitchtip-web:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-web
+ logging: *default-logging
# profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`.
# Bootstrap script activates the profile after DB + secrets are ready.
# On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env.
@@ -254,6 +318,7 @@ services:
glitchtip-worker:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-worker
+ logging: *default-logging
profiles: ["glitchtip"]
depends_on:
postgres:
@@ -272,6 +337,7 @@ services:
caddy:
image: caddy:2
+ logging: *default-logging
restart: unless-stopped
ports:
- "80:80"
@@ -310,6 +376,7 @@ services:
# deploy.yml запускает: docker compose build glitchtip-auth-forwarder
build: ./ops/glitchtip-auth-forwarder
container_name: gendesign-auth-forwarder
+ logging: *default-logging
restart: unless-stopped
environment:
GLITCHTIP_DSN: ${GLITCHTIP_DSN}
From 8def690b003d7f3bf953fceb7560855092a646b5 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 22:37:25 +0000
Subject: [PATCH 011/124] =?UTF-8?q?fix(devops):=20=D0=B4=D0=B0=D1=82=D1=8C?=
=?UTF-8?q?=20trade-in=20=D1=81=D0=B2=D1=8F=D0=B7=D0=BD=D0=BE=D1=81=D1=82?=
=?UTF-8?q?=D1=8C=20=D1=81=20redis,=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BC=20?=
=?UTF-8?q?=D1=83=D0=B6=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=82=D1=8C=20RED?=
=?UTF-8?q?IS=5FURL=20(#2709)=20(#2763)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docker-compose.prod.yml | 34 +++++++++++++++++++++++++++++
tradein-mvp/docker-compose.prod.yml | 15 +++++++++++++
2 files changed, 49 insertions(+)
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 38733e49..9e04bd6b 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -111,6 +111,40 @@ services:
interval: 10s
timeout: 3s
retries: 5
+ # #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ
+ # его достать. До этого redis жил только в gendesign_default, а tradein — в
+ # gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не
+ # резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а
+ # отсутствующая связность (см. #2709).
+ #
+ # Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml
+ # поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES —
+ # ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый
+ # сервис в tradein-compose в этот список не попадает и `--no-deps` его не
+ # подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал
+ # бы в пустоту. Правка того списка = правка deploy-tradein.yml, который
+ # сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком.
+ #
+ # aliases: тот же приём, что уже применён к postgres выше — стабильное имя
+ # gendesign-redis вместо compose-зависимого gendesign-redis-1.
+ # ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется
+ # блок networks:, неявная привязка к default пропадает, и backend/worker/
+ # beat/glitchtip потеряли бы брокер (та же грабля описана у postgres).
+ #
+ # Разделение ключей — по НОМЕРУ БД, инстанс общий:
+ # db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей
+ # db1 — trade-in (SearchCache) ← вводится здесь
+ # db2 — glitchtip (см. REDIS_URL ниже)
+ # Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на
+ # брокере celery вытеснял бы поставленные в очередь таски. Значит tradein
+ # обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если
+ # tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая
+ # celery. Тогда — отдельный инстанс, а не смена политики вытеснения.
+ networks:
+ default: {}
+ shared:
+ aliases:
+ - gendesign-redis
# OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI
# вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze —
diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml
index b7e602a8..c68d74c2 100644
--- a/tradein-mvp/docker-compose.prod.yml
+++ b/tradein-mvp/docker-compose.prod.yml
@@ -206,6 +206,21 @@ services:
# Значение ДОЛЖНО совпадать с TRADEIN_INTERNAL_AUTH_SECRET в .env главного
# (Caddy) стека. Читается также из backend/.env.runtime (env_file выше).
TRADEIN_INTERNAL_AUTH_SECRET: "${TRADEIN_INTERNAL_AUTH_SECRET:-}"
+ # #2709. Дефолт в коде — redis://localhost:6379/0, и внутри контейнера это
+ # мгновенный ConnectionRefused (замер: 16.6 мс) → кэш не работал ни дня.
+ # ⚠️ ПОРЯДОК КРИТИЧЕН, и цена ошибки измерена тем же клиентом, что в
+ # app/services/cache.py (socket_timeout=2.0, socket_connect_timeout=2.0):
+ # localhost, отказ соединения 16.6 мс ← как было
+ # имя не резолвится (NXDOMAIN) 123.5 мс
+ # имя резолвится, но не отвечает 2003.7 мс ← вот эта яма
+ # get/set глотают исключение в logger.warning и возвращают промах, поэтому
+ # яма МОЛЧАЛИВАЯ: 2 с на GET + 2 с на SET на каждый запрос, без единой
+ # ошибки наверх. Отсюда правило: сначала связность, потом переменная.
+ # Здесь она задаётся только потому, что связность уже проверена на проде
+ # ДО этой строки (throwaway-redis на gendesign_shared, из tradein-backend:
+ # PING 19 мс, SET 0.35 мс, GET 0.31 мс) — см. описание PR.
+ # db1 — не 0 и не 2: 0 занят celery-брокером gendesign, 2 — glitchtip'ом.
+ REDIS_URL: "redis://gendesign-redis:6379/1"
depends_on:
browser:
condition: service_started
From 0de22f4bc9828ecd866a85ae9286a991eea089dd Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 6 Aug 2026 23:17:59 +0000
Subject: [PATCH 012/124] =?UTF-8?q?fix(tradein/scraper):=20=D0=B4=D0=B8?=
=?UTF-8?q?=D0=B0=D0=B3=D0=BD=D0=BE=D0=B7=20=D0=B1=D0=B0=D0=BD=D0=B0=20?=
=?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D0=BD?=
=?UTF-8?q?=D0=B0=D0=B7=D0=BD=D0=B0=D1=87=D0=B0=D1=82=D1=8C=D1=81=D1=8F=20?=
=?UTF-8?q?=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8?=
=?UTF-8?q?=D1=8E=20(#2764)=20(#2765)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tradein-mvp/backend/app/api/v1/admin.py | 8 +-
.../backend/app/services/scrape_runs.py | 36 +++-
.../app/tasks/avito_detail_backfill.py | 14 +-
.../app/tasks/domclick_detail_backfill.py | 6 +
.../sql/234_scrape_runs_ban_kind_unknown.sql | 55 +++++
.../backend/data/sql/_manifest_applied.txt | 1 +
tradein-mvp/backend/tests/skip_allowlist.txt | 5 +
.../tests/tasks/test_avito_detail_backfill.py | 44 ++++
.../backend/tests/test_2686_ban_kind_split.py | 21 +-
.../tests/test_2764_ban_kind_no_default.py | 204 ++++++++++++++++++
.../tests/test_backfill_honest_status.py | 4 +-
.../tests/test_scraper_kit_pipeline_parity.py | 2 +-
.../test_scraper_kit_pipeline_parity2.py | 2 +-
.../src/scraper_kit/orchestration/pipeline.py | 19 +-
.../src/scraper_kit/orchestration/runs.py | 17 +-
.../src/scraper_kit/providers/avito/detail.py | 23 +-
16 files changed, 425 insertions(+), 36 deletions(-)
create mode 100644 tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql
create mode 100644 tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py
diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
index 9a9056a6..82c0467a 100644
--- a/tradein-mvp/backend/app/api/v1/admin.py
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -2239,9 +2239,11 @@ class UnifiedScrapeRunRow(BaseModel):
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
# has_running_run перестал бы держать single-run guard.
cancellable: bool = False
- # #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или
- # 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только
- # «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов.
+ # #2686: диагноз для status='banned' — 'platform' (площадка заблокировала),
+ # 'infra' (не отдал наш браузерный сайдкар) или 'unknown' (#2764 — причина не
+ # установлена; раньше такие прогоны молча получали 'platform'). Без него
+ # оператор видит только «забанен» и делает вывод «площадка нас палит» на 80%
+ # наших же отказов.
ban_kind: str | None = None
params: dict | None = None
counters: dict | None = None
diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py
index dd7a56cf..c83f178e 100644
--- a/tradein-mvp/backend/app/services/scrape_runs.py
+++ b/tradein-mvp/backend/app/services/scrape_runs.py
@@ -33,7 +33,7 @@ from __future__ import annotations
import json
import logging
-from collections.abc import Callable, Mapping
+from collections.abc import Callable, Collection, Mapping
from functools import cache
from typing import Any
@@ -116,6 +116,12 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int:
# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние.
BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее
+# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform':
+# на проде оба прогона, помеченных после миграции 218, получили 'platform' по
+# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не
+# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а
+# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения.
+BAN_KIND_UNKNOWN = "unknown"
def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
@@ -515,9 +521,9 @@ def mark_banned(
error: str,
counters: dict[str, int],
*,
- ban_kind: str = BAN_KIND_PLATFORM,
+ ban_kind: str = BAN_KIND_UNKNOWN,
) -> None:
- """Финализация run: status='banned' + диагноз ban_kind (#2686).
+ """Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764).
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
@@ -526,10 +532,11 @@ def mark_banned(
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
- BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha);
- - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси).
+ - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси);
+ - BAN_KIND_UNKNOWN (дефолт) — причина не установлена.
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
- текста ошибки. Default 'platform' = историческая семантика статуса, поэтому
- вызывающие, которым разводить нечего, не меняются.
+ текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому
+ разводить нечего, ничего и не знает — а не «знает, что виновата площадка».
Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом.
@@ -579,6 +586,7 @@ def mark_backfill_finished(
source: str,
aborted_by_blocks: bool = False,
fail_hint: str | None = None,
+ ban_kinds: Collection[str] = (),
) -> None:
"""Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'.
@@ -609,6 +617,13 @@ def mark_backfill_finished(
которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи
контейнера на этот вопрос отвечать не могут — они исчезают при пересоздании
контейнера, то есть на первом же деплое после ночного прогона.
+
+ `ban_kinds` — диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача
+ поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами,
+ в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и
+ пишется; разошлись (или их типы ничего не доказывают) → 'unknown'. Смешанный
+ прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что
+ попалась последней — какая из них оборвала прогон, мы не знаем (#2764).
"""
attempted = int(counters.get("attempted") or 0)
enriched = int(counters.get("enriched") or 0)
@@ -626,7 +641,14 @@ def mark_backfill_finished(
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
- mark_banned(db, run_id, reason, counters)
+ kinds = set(ban_kinds)
+ mark_banned(
+ db,
+ run_id,
+ reason,
+ counters,
+ ban_kind=kinds.pop() if len(kinds) == 1 else BAN_KIND_UNKNOWN,
+ )
return
if produced == 0:
diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py
index fe4ed9c0..d206d869 100644
--- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py
+++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py
@@ -20,6 +20,12 @@ rotate IP on every block, abort after max_consecutive_blocks. Статус об
Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина
отказа пишется в текст статуса прогона (_failure_signature) — иначе она живёт
только в логах контейнера, а те исчезают на первом же деплое.
+
+ДИАГНОЗ бана (#2764): каждый пойманный блок классифицируется по ТИПУ исключения
+(ban_kind_of_exception) и уходит в scrape_runs.ban_kind. До этой правки прогон
+3306 (blocked=5, 0 обогащено) получил 'platform' по УМОЛЧАНИЮ — финализатор
+диагноз не передавал, а browser-режим fetch_detail всё равно превращал отказ
+сайдкара в AvitoBlockedError, так что передавать было бы нечего.
"""
from __future__ import annotations
@@ -40,7 +46,7 @@ from scraper_kit.avito_exceptions import (
AvitoRateLimitedError,
)
from scraper_kit.browser_fetcher import BrowserFetcher
-from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
+from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_exception
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
@@ -365,6 +371,10 @@ async def run_avito_detail_backfill(
# Перепись причин (блоки + отказы) — переживает пересоздание контейнера,
# в отличие от логов; см. _failure_signature.
failure_census: Counter[str] = Counter()
+ # #2764: диагнозы всех блоков прогона по ТИПУ исключения. Сойдутся в один —
+ # он и попадёт в scrape_runs.ban_kind, разойдутся — 'unknown' (схлопывает
+ # mark_backfill_finished, один узел на все три backfill'а).
+ block_ban_kinds: set[str] = set()
for idx, row in enumerate(snapshot):
# Budget guard
@@ -541,6 +551,7 @@ async def run_avito_detail_backfill(
consecutive_blocks += 1
counters.blocked += 1
failure_census[_failure_signature(e)] += 1
+ block_ban_kinds.add(ban_kind_of_exception(e))
do_sleep = False
logger.warning(
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
@@ -657,6 +668,7 @@ async def run_avito_detail_backfill(
source="avito_detail_backfill",
aborted_by_blocks=aborted_by_blocks,
fail_hint=_top_failure(failure_census),
+ ban_kinds=block_ban_kinds,
)
logger.info(
"avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
index 24d63565..0e304651 100644
--- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
+++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
@@ -43,6 +43,12 @@ Exception triad differs from Avito:
No IP-rotation/cooldown recovery step exists here
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an
aborted run simply retries the remaining backlog next window.
+ ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и
+ получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на
+ распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch
+ (наш тракт) -- см. providers/domclick/detail.py::fetch_detail. Пока эти два
+ случая не разведены отдельным подтипом (как AvitoSidecarUnavailableError у
+ avito), любой диагноз отсюда был бы назначенным, а не установленным.
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
block) -- counted as failed++, logged, does NOT touch consecutive_blocks and
does NOT abort the run (neutral to the block-breaker, mirrors how Avito's
diff --git a/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql b/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql
new file mode 100644
index 00000000..ecaead7e
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql
@@ -0,0 +1,55 @@
+-- 234_scrape_runs_ban_kind_unknown.sql
+-- scrape_runs.ban_kind получает третье значение 'unknown' — «причина не установлена» (#2764).
+--
+-- Dependencies: 218_scrape_runs_ban_kind.sql (создала колонку и CHECK по ban_kind).
+-- Apply after: 233_payments.sql
+-- Идемпотентно: DROP/ADD CONSTRAINT + UPDATE, который после первого прогона
+-- никого не находит (условие ban_kind='platform' перестаёт выполняться).
+--
+-- ── ЗАЧЕМ ────────────────────────────────────────────────────────────────────
+-- 218 дала статусу 'banned' диагноз, но дефолт в сигнатуре mark_banned остался
+-- 'platform', а передавали его 3 вызывающих из 13. Замер на проде 2026-08-07:
+-- SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1;
+-- infra | 92 ← ретро-классификация 218 по тексту (разовая, осмысленная)
+-- platform | 41 ← в т.ч. 2 строки, написанные УЖЕ в рантайме, по умолчанию
+-- Обе рантайм-строки пришли из общего финализатора backfill-задач
+-- (scrape_runs.mark_backfill_finished, #2674), который диагноз не передаёт вовсе:
+-- 3306 avito_detail_backfill 2026-08-06 12:42 blocked=5, 0 обогащено из 5
+-- 3313 domclick_detail_backfill 2026-08-06 15:20 blocked=3, 0 обогащено из 3
+-- Причина отказов ни у одной из них не устанавливалась. Метка 'platform' у них —
+-- не вывод, а значение по умолчанию, и выглядит она ровно как доказанные 10
+-- строк «Avito SERP firewall». Оставить их — значит сохранить ложное
+-- доказательство ровно того класса, ради которого 218 и делалась.
+--
+-- ── ГРАНИЦА ПРАВКИ ───────────────────────────────────────────────────────────
+-- Ретро-классификацию 218 (по тексту ошибки, применена 2026-08-06 08:24:19 UTC)
+-- НЕ трогаем: разбор текста был там законным — типов исключений у исторических
+-- строк уже нет, а маркер сайдкара в тексте есть. Трогаем только строки, которые
+-- та миграция УЖЕ НЕ ВИДЕЛА (finished_at позже её применения) и которые пришли из
+-- финализатора backfill'ов — единственного вызывающего, который на тот момент
+-- писал ban_kind по умолчанию. Условие по error здесь опознаёт ВЫЗЫВАЮЩЕГО
+-- (префикс, который он сам ставит), а не причину отказа: диагноз из текста не
+-- выводится ни здесь, ни тем более в рантайме.
+--
+-- Верхней границы по времени нет намеренно: миграции применяются деплоем ДО
+-- рестарта приложения, но если между этим и новым кодом финализатор успеет
+-- пометить ещё строки — они тоже написаны дефолтом и тоже подлежат переводу.
+
+ALTER TABLE scrape_runs DROP CONSTRAINT IF EXISTS scrape_runs_ban_kind_check;
+ALTER TABLE scrape_runs
+ ADD CONSTRAINT scrape_runs_ban_kind_check
+ CHECK (ban_kind IS NULL OR ban_kind IN ('platform', 'infra', 'unknown'));
+
+COMMENT ON COLUMN scrape_runs.ban_kind IS
+ 'Диагноз status=''banned'' (#2686, #2764): platform — площадка заблокировала '
+ '(firewall/403/captcha, доказано типом исключения); infra — не отдала НАША '
+ 'инфраструктура (браузерный сайдкар/прокси); unknown — причина НЕ установлена '
+ '(дефолт mark_banned). NULL для прогонов с другим статусом. Пишется из типа '
+ 'исключения в момент отказа, не из текста ошибки.';
+
+UPDATE scrape_runs
+SET ban_kind = 'unknown'
+WHERE status = 'banned'
+ AND ban_kind = 'platform'
+ AND finished_at > TIMESTAMPTZ '2026-08-06 08:24:19.102335+00' -- 218 applied_at
+ AND error LIKE 'backfill-honest-status:%';
diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt
index 52a76097..bbb3f066 100644
--- a/tradein-mvp/backend/data/sql/_manifest_applied.txt
+++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt
@@ -240,3 +240,4 @@
222_db_audit_cleanup.sql
225_listing_source_snapshots_run_id_idx.sql
233_payments.sql
+234_scrape_runs_ban_kind_unknown.sql
diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt
index 02f7d075..91ebbc03 100644
--- a/tradein-mvp/backend/tests/skip_allowlist.txt
+++ b/tradein-mvp/backend/tests/skip_allowlist.txt
@@ -46,3 +46,8 @@ tests/test_data_erasure.py::test_real_erase_by_phone_does_not_match_different_nu
tests/test_data_erasure.py::test_real_erase_by_phone_finds_differently_formatted_number
tests/test_data_erasure.py::test_real_erase_by_phone_finds_ru_trunk_prefix_variant
tests/test_purge_expired_trade_in_data.py::test_real_purge_deletes_only_anonymous_expired_estimates
+
+# Диагноз оборванного прогона (#2764) — тот же `_live_session()`. Проверяет, что
+# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
+# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
+tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
index 4bc663d8..333da637 100644
--- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
+++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
@@ -4,6 +4,7 @@ import asyncio
import fnmatch
import os
import sys
+from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@@ -12,6 +13,10 @@ _wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
+from scraper_kit.avito_exceptions import ( # noqa: E402
+ AvitoBlockedError,
+ AvitoSidecarUnavailableError,
+)
from app.core import shutdown as _sd # noqa: E402
from app.tasks.avito_detail_backfill import ( # noqa: E402
@@ -192,6 +197,45 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
runs.mark_failed.assert_not_called()
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("exc_factory", "expected_kinds"),
+ [
+ (lambda: AvitoBlockedError("ip blocked"), {"platform"}),
+ (lambda: AvitoSidecarUnavailableError("browser unavailable"), {"infra"}),
+ ],
+)
+async def test_backfill_reports_ban_kind_of_the_blocks_it_saw(
+ exc_factory: Any, expected_kinds: set[str]
+) -> None:
+ """Диагноз блоков доезжает до финализатора по ТИПУ исключения (#2764).
+
+ Фальсификация: до правки задача не передавала ничего, и обе серии — отказ
+ площадки и отказ нашего сайдкара — давали в scrape_runs.ban_kind одинаковое
+ 'platform' по умолчанию (прод, прогон 3306: blocked=5, причина не установлена).
+ """
+ snapshot = _make_snapshot(10)
+ db = _mock_db(snapshot)
+ runs = MagicMock()
+ mock_scraper = MagicMock()
+ mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
+ fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False)
+ with (
+ patch(_SETTINGS, fake_settings),
+ patch(_SESSION, return_value=AsyncMock()),
+ patch(_SCRAPER, mock_scraper),
+ patch(_RUNS, runs),
+ patch(_FETCH, AsyncMock(side_effect=exc_factory())),
+ patch(_SLEEP, new_callable=AsyncMock),
+ ):
+ await run_avito_detail_backfill(
+ db, run_id=3, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5}
+ )
+
+ runs.mark_backfill_finished.assert_called_once()
+ assert set(runs.mark_backfill_finished.call_args.kwargs["ban_kinds"]) == expected_kinds
+
+
@pytest.mark.asyncio
async def test_backfill_blocked_abort_after_max_consecutive() -> None:
"""5 consecutive AvitoBlockedError -> abort с пометкой aborted_by_blocks (#2674).
diff --git a/tradein-mvp/backend/tests/test_2686_ban_kind_split.py b/tradein-mvp/backend/tests/test_2686_ban_kind_split.py
index 1b4e1121..53492581 100644
--- a/tradein-mvp/backend/tests/test_2686_ban_kind_split.py
+++ b/tradein-mvp/backend/tests/test_2686_ban_kind_split.py
@@ -101,11 +101,15 @@ async def test_platform_firewall_is_not_our_infra() -> None:
(AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA),
(AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM),
(AvitoRateLimitedError("HTTP 429"), kit_runs.BAN_KIND_PLATFORM),
- (RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_PLATFORM),
+ (RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_UNKNOWN),
],
)
def test_ban_kind_of_exception(exc: BaseException, expected: str) -> None:
- """Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип."""
+ """Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип.
+
+ Тип, который НИЧЕГО не доказывает, с #2764 даёт 'unknown': `else PLATFORM` здесь
+ был тем же назначенным диагнозом, что и дефолт mark_banned, только уровнем ниже.
+ """
assert ban_kind_of_exception(exc) == expected
@@ -138,7 +142,7 @@ class _BanRecorder:
error: str,
counters: dict[str, Any],
*,
- ban_kind: str = kit_runs.BAN_KIND_PLATFORM,
+ ban_kind: str = kit_runs.BAN_KIND_UNKNOWN,
) -> None:
self.banned.append((ban_kind, dict(counters)))
@@ -193,7 +197,8 @@ async def test_full_load_sidecar_ban_is_infra_and_keeps_checkpoint() -> None:
Фальсификация: до правки mark_banned не принимал ban_kind вовсе — вызов падал
бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы
- 'platform' (дефолт), то есть ровно то враньё, которое стоило миграции 206.
+ 'unknown' (дефолт с #2764; до него — 'platform', ровно то враньё, которое
+ стоило миграции 206).
"""
recorder = await _drive_full_load(
AvitoSidecarUnavailableError("avito SERP browser-sidecar error (page=1)")
@@ -225,12 +230,16 @@ async def test_full_load_platform_ban_keeps_the_same_checkpoint() -> None:
@pytest.mark.parametrize("module_name", ["kit", "app"])
@pytest.mark.parametrize(
("kwargs", "expected"),
- [({}, "platform"), ({"ban_kind": "infra"}, "infra")],
+ [({}, "unknown"), ({"ban_kind": "infra"}, "infra")],
)
def test_mark_banned_writes_ban_kind(
module_name: str, kwargs: dict[str, str], expected: str
) -> None:
- """Обе копии runs-модуля кладут ban_kind в UPDATE (default — историческое 'platform')."""
+ """Обе копии runs-модуля кладут ban_kind в UPDATE; дефолт — 'unknown' (#2764).
+
+ Дефолт был 'platform' — и на проде обе строки, помеченные уже в рантайме, взяли
+ именно его: метка выглядела доказательством, не будучи им.
+ """
if module_name == "kit":
mod: Any = kit_runs
else:
diff --git a/tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py b/tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py
new file mode 100644
index 00000000..227631ec
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py
@@ -0,0 +1,204 @@
+"""#2764: ban_kind перестаёт проставляться по умолчанию — метка либо доказана, либо 'unknown'.
+
+Замер на проде 2026-08-07 (после миграции 218 и мержа #2711):
+ SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1;
+ infra 92 / platform 41
+Из них ДВЕ строки написаны уже в рантайме — и обе получили 'platform' по дефолту
+сигнатуры mark_banned, из общего финализатора backfill-задач, который диагноз не
+передаёт вовсе (3306 avito 12:42, 3313 domclick 15:20). Для avito причина 1600
+отказов вообще не устанавливалась, и всё равно была записана блокировкой площадкой.
+
+Проверяем четыре звена:
+ 1. дефолт mark_banned = 'unknown' (обе копии runs-модуля) — test_2686 §4;
+ 2. финализатор backfill'ов несёт диагноз, когда задача его знает, и 'unknown',
+ когда нет или когда блоки разошлись в диагнозах;
+ 3. browser-режим fetch_detail больше не выдаёт отказ НАШЕГО сайдкара за
+ блокировку площадкой (зеркало #2686 для SERP-ветки);
+ 4. 'unknown' проходит CHECK-констрейнт (миграция 234) — иначе запись упала бы
+ на проде, а падение внутри задачи выглядело бы как обычный failed-прогон.
+"""
+
+from __future__ import annotations
+
+import os
+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:5432/test")
+
+from scraper_kit.avito_exceptions import (
+ AvitoBlockedError,
+ AvitoRateLimitedError,
+ AvitoSidecarUnavailableError,
+)
+from scraper_kit.orchestration.pipeline import ban_kind_of_exception
+from scraper_kit.providers.avito.detail import fetch_detail
+
+from app.services import scrape_runs as runs_mod
+
+_BLOCKED_RUN = {"attempted": 5, "enriched": 0, "blocked": 5, "failed": 0}
+
+
+def _ban_kind_of_finished(**kwargs: Any) -> str:
+ """mark_backfill_finished на оборванном блоками прогоне → ban_kind, ушедший в mark_banned."""
+ recorded: list[str] = []
+ with patch.object(
+ runs_mod,
+ "mark_banned",
+ lambda db, rid, err, c, *, ban_kind: recorded.append(ban_kind),
+ ):
+ runs_mod.mark_backfill_finished(
+ MagicMock(),
+ 1,
+ dict(_BLOCKED_RUN),
+ source="avito_detail_backfill",
+ aborted_by_blocks=True,
+ **kwargs,
+ )
+ assert len(recorded) == 1, f"ожидался ровно один mark_banned, получено {recorded}"
+ return recorded[0]
+
+
+# ── 1. Финализатор backfill'ов: диагноз или честное «не знаю» ────────────────
+
+
+def test_finalizer_without_diagnosis_writes_unknown() -> None:
+ """Задача типы не различает → 'unknown'.
+
+ Фальсификация: до правки этот вызывающий (единственный источник обеих
+ рантайм-строк на проде) не передавал ничего и получал дефолт 'platform' —
+ assert падает на старом коде.
+ """
+ assert _ban_kind_of_finished() == runs_mod.BAN_KIND_UNKNOWN
+
+
+def test_finalizer_carries_the_diagnosis_when_all_blocks_agree() -> None:
+ """Все блоки прогона — отказ нашего сайдкара → 'infra' доезжает до строки прогона."""
+ assert _ban_kind_of_finished(ban_kinds={runs_mod.BAN_KIND_INFRA}) == runs_mod.BAN_KIND_INFRA
+ assert (
+ _ban_kind_of_finished(ban_kinds=[runs_mod.BAN_KIND_PLATFORM, runs_mod.BAN_KIND_PLATFORM])
+ == runs_mod.BAN_KIND_PLATFORM
+ )
+
+
+def test_finalizer_with_mixed_diagnoses_writes_unknown() -> None:
+ """Блоки разошлись → 'unknown': какой из них оборвал прогон, мы не знаем.
+
+ Выбрать «последний попавшийся» значило бы вернуть назначенный диагноз под
+ другим именем.
+ """
+ assert (
+ _ban_kind_of_finished(ban_kinds={runs_mod.BAN_KIND_INFRA, runs_mod.BAN_KIND_PLATFORM})
+ == runs_mod.BAN_KIND_UNKNOWN
+ )
+
+
+# ── 2. Диагноз не врёт там, где он передаётся: browser-ветка fetch_detail ────
+
+
+def _browser(*, side_effect: BaseException | None = None, html: str = "") -> Any:
+ bf = MagicMock()
+ bf.fetch = AsyncMock(side_effect=side_effect, return_value=html)
+ return bf
+
+
+def _sidecar_503() -> httpx.HTTPStatusError:
+ """Ровно то, что прод писал 90 раз с 05.07 по 03.08 (см. миграцию 218)."""
+ request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
+ response = httpx.Response(
+ 503, json={"error": "browser unavailable (proxy may be down)"}, request=request
+ )
+ return httpx.HTTPStatusError("503", request=request, response=response)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "sidecar_failure",
+ [_sidecar_503(), httpx.ConnectError("connection refused"), httpx.ReadTimeout("read timeout")],
+)
+async def test_browser_detail_sidecar_failure_is_infra(sidecar_failure: BaseException) -> None:
+ """Сайдкар не отдал страницу → диагноз 'infra', поведение прогона прежнее.
+
+ Фальсификация: до правки ветка поднимала AvitoBlockedError на ЛЮБОЙ отказ
+ сайдкара, то есть ban_kind_of_exception возвращал 'platform' — «площадка нас
+ заблокировала» на странице, которая до площадки не доехала.
+ Второй assert стережёт совместимость: все существующие
+ `except (AvitoBlockedError, AvitoRateLimitedError)` обязаны продолжать ловить
+ этот отказ, иначе прогон уйдёт в mark_failed и потеряет чекпоинт.
+ """
+ with pytest.raises(AvitoSidecarUnavailableError) as excinfo:
+ await fetch_detail("/items/1", browser_fetcher=_browser(side_effect=sidecar_failure))
+
+ assert isinstance(excinfo.value, AvitoRateLimitedError)
+ assert ban_kind_of_exception(excinfo.value) == runs_mod.BAN_KIND_INFRA
+
+
+@pytest.mark.asyncio
+async def test_browser_detail_firewall_page_stays_platform() -> None:
+ """Площадка ПОКАЗАЛА firewall → 'platform' остаётся: правка сужает метку, а не снимает."""
+ firewall = "Доступ ограничен: проблема с IP"
+
+ with pytest.raises(AvitoBlockedError) as excinfo:
+ await fetch_detail("/items/1", browser_fetcher=_browser(html=firewall))
+
+ assert not isinstance(excinfo.value, AvitoSidecarUnavailableError)
+ assert ban_kind_of_exception(excinfo.value) == runs_mod.BAN_KIND_PLATFORM
+
+
+# ── 3. 'unknown' проходит CHECK-констрейнт (миграция 234) ────────────────────
+
+
+def _live_session() -> Any | None:
+ """Session к живой Postgres, если она есть (в CI есть — ci-tradein.yml, #2745)."""
+ try:
+ from sqlalchemy import create_engine, text
+ from sqlalchemy.orm import sessionmaker
+
+ dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
+ if not dsn or "localhost:5432/test" in dsn:
+ return None
+ engine = create_engine(dsn, future=True)
+ conn = engine.connect()
+ conn.execute(text("SELECT 1"))
+ conn.close()
+ return sessionmaker(bind=engine, future=True)()
+ except Exception:
+ return None
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+def test_real_default_ban_kind_survives_the_check_constraint() -> None:
+ """mark_banned без диагноза пишет 'unknown' в живую схему.
+
+ Фальсификация: без миграции 234 CHECK разрешает только platform/infra — UPDATE
+ падает на IntegrityError. На проде это падение было бы неотличимо от обычного
+ сбоя задачи (внешний `except Exception` backfill'а увёл бы прогон в
+ mark_failed, потеряв done_buckets-чекпоинт).
+ """
+ from sqlalchemy import text
+
+ db = _live_session()
+ assert db is not None
+ try:
+ run_id = db.execute(
+ text(
+ "INSERT INTO scrape_runs (source, status) "
+ "VALUES ('test_2764', 'running') RETURNING id"
+ )
+ ).scalar_one()
+ db.commit()
+
+ runs_mod.mark_banned(db, int(run_id), "test 2764", {"lots_fetched": 0})
+
+ row = db.execute(
+ text("SELECT status, ban_kind FROM scrape_runs WHERE id = :id"), {"id": run_id}
+ ).one()
+ assert row.status == "banned"
+ assert row.ban_kind == runs_mod.BAN_KIND_UNKNOWN
+ finally:
+ db.execute(text("DELETE FROM scrape_runs WHERE source = 'test_2764'"))
+ db.commit()
+ db.close()
diff --git a/tradein-mvp/backend/tests/test_backfill_honest_status.py b/tradein-mvp/backend/tests/test_backfill_honest_status.py
index 327e32ac..ad72db34 100644
--- a/tradein-mvp/backend/tests/test_backfill_honest_status.py
+++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py
@@ -29,8 +29,10 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st
patch.object(
runs_mod, "mark_failed", lambda db, rid, err, c: calls.append(("failed", err))
),
+ # **k — mark_banned принимает ещё и ban_kind (#2686/#2764); этот тест про
+ # ВЫБОР финализатора, диагноз проверяет test_2764_ban_kind_no_default.py.
patch.object(
- runs_mod, "mark_banned", lambda db, rid, err, c: calls.append(("banned", err))
+ runs_mod, "mark_banned", lambda db, rid, err, c, **k: calls.append(("banned", err))
),
):
runs_mod.mark_backfill_finished(
diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py
index f0615237..f3091685 100644
--- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py
+++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py
@@ -61,7 +61,7 @@ class _RunsRecorder:
error: str,
counters: dict[str, Any],
*,
- ban_kind: str = "platform",
+ ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля
) -> None:
self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус
self.calls.append(("mark_banned", dict(counters)))
diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py
index 0c1dc4c4..ba629b56 100644
--- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py
+++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py
@@ -71,7 +71,7 @@ class _RunsRecorder:
error: str,
counters: dict[str, Any],
*,
- ban_kind: str = "platform",
+ ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля
) -> None:
self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус
self.calls.append(("mark_banned", dict(counters)))
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 02fae3f5..a900f996 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
@@ -56,7 +56,7 @@ from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration import runs
# Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан
# работать и когда тесты подменяют весь модуль runs двойником (#2686).
-from scraper_kit.orchestration.runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM
+from scraper_kit.orchestration.runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM, BAN_KIND_UNKNOWN
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.avito.houses import (
fetch_house_catalog,
@@ -196,14 +196,23 @@ def ban_kind_of_exception(exc: BaseException) -> str:
"""Диагноз для scrape_runs.ban_kind по ТИПУ исключения, не по тексту (#2686).
AvitoSidecarUnavailableError поднимается ровно там, где отказ породила наша
- инфраструктура (браузерный сайдкар не отдал страницу) — всё остальное, что
- доводит прогон до 'banned' (firewall, 403/captcha, честный 429 площадки, N
- подряд заблокированных якорей), внешнее.
+ инфраструктура (браузерный сайдкар не отдал страницу) → 'infra'.
+ AvitoBlockedError (firewall/403/captcha/content-block) и честный 429 площадки —
+ единственные типы, которыми площадка себя ПОКАЗАЛА → 'platform'.
+
+ Всё остальное → 'unknown' (#2764). Раньше здесь стоял `else PLATFORM`, то есть
+ любой RuntimeError/timeout/сетевой сбой объявлялся блокировкой площадкой — тот
+ же дефект, что и дефолт mark_banned, только уровнем ниже: диагноз назначался,
+ а не устанавливался.
Один узел на все avito-сайты mark_banned: разводить исход в каждом из трёх
было бы тремя копиями одного условия.
"""
- return BAN_KIND_INFRA if isinstance(exc, AvitoSidecarUnavailableError) else BAN_KIND_PLATFORM
+ if isinstance(exc, AvitoSidecarUnavailableError):
+ return BAN_KIND_INFRA
+ if isinstance(exc, AvitoBlockedError | AvitoRateLimitedError):
+ return BAN_KIND_PLATFORM
+ return BAN_KIND_UNKNOWN
# #2160: константы для расчёта watchdog-таймаута Cian city sweep. При
# USE_PROXY_POOL_BROWSER=true каждый SERP-фетч идёт через camoufox с relaunch при смене
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 d3fd9c01..1389e9b4 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
@@ -111,6 +111,12 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int:
# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние.
BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее
+# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform':
+# на проде оба прогона, помеченных после миграции 218, получили 'platform' по
+# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не
+# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а
+# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения.
+BAN_KIND_UNKNOWN = "unknown"
def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
@@ -580,9 +586,9 @@ def mark_banned(
error: str,
counters: dict[str, int],
*,
- ban_kind: str = BAN_KIND_PLATFORM,
+ ban_kind: str = BAN_KIND_UNKNOWN,
) -> None:
- """Финализация run: status='banned' + диагноз ban_kind (#2686).
+ """Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764).
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
@@ -591,10 +597,11 @@ def mark_banned(
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
- BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha);
- - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси).
+ - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси);
+ - BAN_KIND_UNKNOWN (дефолт) — причина не установлена.
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
- текста ошибки. Default 'platform' = историческая семантика статуса, поэтому
- вызывающие, которым разводить нечего, не меняются.
+ текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому
+ разводить нечего, ничего и не знает — а не «знает, что виновата площадка».
Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом.
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
index 0b6c90e7..60aaeb40 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
@@ -38,6 +38,7 @@ from scraper_kit.avito_exceptions import (
AvitoBlockedError,
AvitoListingGoneError,
AvitoRateLimitedError,
+ AvitoSidecarUnavailableError,
)
from scraper_kit.geo import is_within_oblast66_bbox
from scraper_kit.providers._base import build_document_session
@@ -389,13 +390,16 @@ async def fetch_detail(
Raises:
httpx.HTTPError — если status != 200 (через raise_for_status-like).
ValueError — если item_id не извлечён из HTML.
+ AvitoSidecarUnavailableError — browser-режим: сайдкар не отдал страницу
+ (наш тракт, не площадка; подтип AvitoRateLimitedError — #2764).
"""
if browser_fetcher is not None:
# Browser-only режим (#1814): когда передан browser_fetcher, fetch идёт
# СТРОГО через camoufox-сервис. Никакой curl_cffi-сессии и никакого
# curl-fallback — avito-антибот банит curl-фингерпринт под нагрузкой
- # (429 CONNECT tunnel failed), а browser проходит. Любой firewall/ошибка
- # браузера → AvitoBlockedError; caller (backfill) пометит failed и пойдёт
+ # (429 CONNECT tunnel failed), а browser проходит. Отказ САЙДКАРА и
+ # блокировку ПЛОЩАДКОЙ различаем (#2764, см. except-ветки ниже); прогон в
+ # обоих случаях ведёт себя одинаково — caller пометит listing и пойдёт
# дальше, НЕ трогая curl.
full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url)
try:
@@ -403,10 +407,17 @@ async def fetch_detail(
except (AvitoBlockedError, AvitoRateLimitedError):
raise
except Exception as exc:
- # browser-сервис вернул HTTPError / ConnectError / 502 (firewall, dead
- # listing, redirect). НЕ откатываемся в curl — поднимаем block, чтобы
- # caller пометил listing и продолжил без curl-фингерпринта.
- raise AvitoBlockedError(
+ # Сайдкар не отдал страницу: HTTPStatusError (503 «browser unavailable»,
+ # 502), ConnectError/ReadTimeout до сервиса, NoProxyAvailableError пустого
+ # пула. Площадка тут ни при чём — страница до неё не доехала, и её ответ
+ # мы не видели. Раньше здесь поднимался AvitoBlockedError, то есть ЛЮБОЙ
+ # отказ нашего тракта доезжал до scrape_runs.ban_kind как «блокировка
+ # площадкой» (#2764 п.3) — при том, что зеркальная SERP-ветка
+ # (providers/avito/serp.py, #2686) ровно эти же отказы уже помечала infra.
+ # Подтип AvitoRateLimitedError — поведение прогона прежнее: все
+ # `except (AvitoBlockedError, AvitoRateLimitedError)` (backfill, pipeline
+ # detail/house-фазы) ловят его без изменений.
+ raise AvitoSidecarUnavailableError(
f"Avito detail browser fetch failed for {full_url}: {exc}"
) from exc
# 404/removed-listing (#2034): мёртвый листинг рендерит «Ошибка 404» без
From a034d7a1c11b3b8c419b41710f653dddc2f95714 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Fri, 7 Aug 2026 07:55:37 +0000
Subject: [PATCH 013/124] =?UTF-8?q?test(tradein/frontend):=20=D0=BF=D0=B5?=
=?UTF-8?q?=D1=80=D0=B2=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?=
=?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8?=
=?UTF-8?q?=D1=8F=20=E2=80=94=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B5=20?=
=?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B5,=20=D0=BF=D0=BE=D0=B3=D0=B0=D1=88?=
=?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D0=B5=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE?=
=?UTF-8?q?,=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20429=20(#2766)=20(#2769)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.forgejo/workflows/ci-tradein.yml | 14 ++
tradein-mvp/frontend/package.json | 10 +-
.../app/login/__tests__/LoginPage.test.tsx | 111 +++++++++++++
.../trade-in/__tests__/SaleShareList.test.tsx | 106 +++++++++++++
.../__tests__/StreetDealsCard.test.tsx | 150 ++++++++++++++++++
tradein-mvp/frontend/vitest-env.d.ts | 2 +
tradein-mvp/frontend/vitest.config.ts | 21 +++
tradein-mvp/frontend/vitest.setup.ts | 3 +
8 files changed, 416 insertions(+), 1 deletion(-)
create mode 100644 tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
create mode 100644 tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx
create mode 100644 tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx
create mode 100644 tradein-mvp/frontend/vitest-env.d.ts
create mode 100644 tradein-mvp/frontend/vitest.config.ts
create mode 100644 tradein-mvp/frontend/vitest.setup.ts
diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml
index 5b228267..9315e1a6 100644
--- a/.forgejo/workflows/ci-tradein.yml
+++ b/.forgejo/workflows/ci-tradein.yml
@@ -304,6 +304,20 @@ jobs:
# Blocking: любая TS-ошибка → job RED.
run: npm run type-check
+ - name: Run tests (vitest)
+ # Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ
+ # проверки поведения: лэйн гейтил только типы и статический анализ, а оба
+ # молчат про то, что видит пользователь — пустое поле, погашенное число,
+ # отказ по частоте. Инфраструктура не изобретена, а взята у соседнего
+ # frontend/ (vitest + jsdom + testing-library), где сьют живёт давно.
+ #
+ # Пропусков в сьюте нет и быть не должно: сторож пропусков
+ # (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому
+ # пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722
+ # запретил на бэкенде. Тест либо чинится, либо помечается `.fails`
+ # с причиной В КОДЕ.
+ run: npm test
+
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint
diff --git a/tradein-mvp/frontend/package.json b/tradein-mvp/frontend/package.json
index f71653ba..ef448501 100644
--- a/tradein-mvp/frontend/package.json
+++ b/tradein-mvp/frontend/package.json
@@ -8,6 +8,8 @@
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
"check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs"
},
"dependencies": {
@@ -18,12 +20,18 @@
"recharts": "^2.15.4"
},
"devDependencies": {
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@eslint/eslintrc": "^3.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
- "typescript": "^5.9.3"
+ "jsdom": "^25.0.1",
+ "typescript": "^5.9.3",
+ "vitest": "^2.1.9"
}
}
diff --git a/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
new file mode 100644
index 00000000..d82503dc
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
@@ -0,0 +1,111 @@
+/**
+ * #2766 — отказ по частоте на форме входа должен читаться как отказ по частоте.
+ *
+ * PR #2712/#2717 ввели 429 при насыщении проверок пароля. До этого любой не-401
+ * падал в общую фразу «Не удалось войти…», а человек, которого притормозил
+ * лимит, читал её как проблему сети и долбил форму дальше. Здесь закреплено,
+ * что 429 даёт СВОЁ сообщение, и что оно не путается ни с «неверный пароль»,
+ * ни с общей ошибкой.
+ *
+ * Подменяется `fetch`, а не `loginErrorMessage`: так в тесте работает настоящая
+ * `apiFetch` и настоящий `HTTPError` — то есть проверяется весь путь от кода
+ * ответа до текста на экране, а не одна функция в вакууме.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import LoginPage from "../page";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
+}));
+
+/**
+ * Ответ на POST /auth/login задаётся тестом; всё остальное (поллинг непрочитанных
+ * у виджета поддержки на этой же странице) отвечает пустым 200, чтобы фон не
+ * подмешивал свои ошибки в проверяемое состояние формы.
+ */
+function stubFetch(loginStatus: number, loginBody: unknown) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : String(input);
+ if (url.includes("/auth/login")) {
+ return new Response(JSON.stringify(loginBody), {
+ status: loginStatus,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response("{}", { status: 200 });
+ }),
+ );
+}
+
+function renderLogin() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+function submitCredentials() {
+ fireEvent.change(screen.getByLabelText("Логин"), {
+ target: { value: "praktika" },
+ });
+ fireEvent.change(screen.getByLabelText("Пароль"), {
+ target: { value: "hunter2" },
+ });
+ fireEvent.submit(screen.getByRole("form", { name: "Вход в Меру" }));
+}
+
+describe("LoginPage — сообщение об ошибке входа", () => {
+ beforeEach(() => vi.unstubAllGlobals());
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("429 — сообщение про частоту попыток, а не про пароль и не общее", async () => {
+ stubFetch(429, { detail: "Too Many Requests" });
+ renderLogin();
+ submitCredentials();
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Слишком много попыток. Попробуйте через несколько минут",
+ );
+ expect(alert).not.toHaveTextContent("Неверный логин или пароль");
+ expect(alert).not.toHaveTextContent("Не удалось войти");
+ });
+
+ it("401 — сообщение про пароль (429-ветка не поглощает остальные коды)", async () => {
+ stubFetch(401, { detail: "Invalid credentials" });
+ renderLogin();
+ submitCredentials();
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("Неверный логин или пароль");
+ expect(alert).not.toHaveTextContent("Слишком много попыток");
+ });
+
+ it("500 — общая фраза (её текст не должен подменять собой 429)", async () => {
+ stubFetch(500, { detail: "boom" });
+ renderLogin();
+ submitCredentials();
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Не удалось войти. Проверьте подключение и попробуйте ещё раз",
+ );
+ });
+
+ it("до отправки формы сообщения об ошибке нет", async () => {
+ stubFetch(429, { detail: "Too Many Requests" });
+ renderLogin();
+ await waitFor(() =>
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
+ );
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx
new file mode 100644
index 00000000..c3a0aa49
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx
@@ -0,0 +1,106 @@
+/**
+ * #2766 — что показывает карточка дома, когда поле пришло пустым.
+ *
+ * Повод: `series_name` пуст у всех 9 457 домов на проде (#2700). Строка «Дом»
+ * склеивается из четырёх полей через « · », и вопрос не в том, есть ли серия,
+ * а в том, ЧТО видит пользователь на её месте: висящий разделитель, слово
+ * «null» — или ничего. Здесь закреплён последний вариант.
+ *
+ * Проверка целится в текст ячейки ЦЕЛИКОМ (`toBe`, не `toContain`) — именно
+ * хвостовой разделитель отличает «пришло пусто, нарисовали пусто» от
+ * «пришло пусто, нарисовали дыру», и подстрочное совпадение его не заметит.
+ */
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { SaleShareList } from "../SaleShareList";
+import type { BuildingSaleShare } from "@/types/sale-share";
+
+function makeBuilding(
+ overrides: Partial = {},
+): BuildingSaleShare {
+ return {
+ house_id: 1,
+ address: "Ленина, 1",
+ lat: 56.83,
+ lon: 60.6,
+ sale_share_pct: 12.5,
+ sale_share_pct_45d: null,
+ listings_45d: null,
+ over_100: false,
+ active_secondary: 8,
+ flat_count_effective: 64,
+ gar_match_method: "exact",
+ median_price_rub: 6_500_000,
+ median_price_per_m2: 145_000,
+ avg_days_on_market: 42,
+ year_built: 1979,
+ house_type: "panel",
+ total_floors: 9,
+ series_name: "1-464Д",
+ is_emergency: false,
+ ...overrides,
+ };
+}
+
+function renderList(buildings: BuildingSaleShare[]) {
+ return render(
+ ,
+ );
+}
+
+/** Текст последней содержательной ячейки строки — колонка «Дом». */
+function houseMetaText(address: string): string {
+ const row = screen.getByText(address).closest("tr");
+ if (row === null) throw new Error(`строка с адресом ${address} не отрисована`);
+ // Колонки: Адрес · Доля · В продаже · Медиана · Экспозиция · Дом · Действия.
+ return row.cells[5].textContent ?? "";
+}
+
+describe("SaleShareList — колонка «Дом» при пустом поле", () => {
+ it("серия есть — попадает в строку последним элементом", () => {
+ renderList([makeBuilding({ series_name: "1-464Д" })]);
+ expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт. · 1-464Д");
+ });
+
+ it("series_name = null — строка без хвостового разделителя (прод-состояние #2700)", () => {
+ renderList([makeBuilding({ series_name: null })]);
+ expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт.");
+ });
+
+ it("series_name = пустая строка — тоже без хвостового разделителя", () => {
+ renderList([makeBuilding({ series_name: "" })]);
+ expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт.");
+ });
+
+ it("пусты все поля дома — прочерк, а не пустая ячейка и не «null»", () => {
+ renderList([
+ makeBuilding({
+ year_built: null,
+ house_type: null,
+ total_floors: null,
+ series_name: null,
+ }),
+ ]);
+ expect(houseMetaText("Ленина, 1")).toBe("—");
+ });
+
+ it("пустая серия не съедает остальные поля дома", () => {
+ renderList([
+ makeBuilding({ series_name: null, house_type: null, year_built: null }),
+ ]);
+ expect(houseMetaText("Ленина, 1")).toBe("9 эт.");
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx
new file mode 100644
index 00000000..c6e8a77d
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx
@@ -0,0 +1,150 @@
+/**
+ * #2766 — погашенное число медианного торга обязано быть ОБЪЯСНЕНО.
+ *
+ * Гейт правдоподобия (#2666 + #2672, PR #2706) сократил показываемые числа
+ * вдвое: 64 группы из 128 → 32 после порога по различным объявлениям. Бэкенд в
+ * этом случае обнуляет `median_discount_pct` и кладёт в
+ * `median_discount_explanation` текст, называющий оба числа (сколько сделок и
+ * сколько разных объявлений). Пользователь должен увидеть этот текст — не
+ * пустое место (читается как поломка виджета) и не «0%» (читается как «торга
+ * нет»).
+ *
+ * Оба живых потребителя проверяются здесь, потому что гейт один, а мест показа
+ * два: карточка `/` (StreetDealsCard) и HUD `/v2` (mapHistory → dkpHeader.note).
+ */
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { UseQueryResult } from "@tanstack/react-query";
+
+import { StreetDealsCard } from "../StreetDealsCard";
+import { mapHistory } from "../v2/mappers";
+import { useSalesVsListings } from "@/lib/trade-in-api";
+import type {
+ AggregatedEstimate,
+ SalesListingPair,
+ SalesVsListingsResponse,
+} from "@/types/trade-in";
+
+vi.mock("@/lib/trade-in-api", () => ({
+ useSalesVsListings: vi.fn(),
+}));
+
+// Текст ровно в той форме, в какой его строит бэкенд для случая «мало разных
+// объявлений» (app/api/v1/trade_in.py, ветка n_distinct_listings < порога).
+const GATED_EXPLANATION =
+ "Медианный торг не показываем: сделок 50, а разных объявлений для сравнения " +
+ "всего 1 — такой процент говорит о цене одной конкретной квартиры, а не о " +
+ "торге на улице.";
+
+function makePair(overrides: Partial = {}): SalesListingPair {
+ return {
+ deal_id: 1,
+ deal_date: "2026-03-15",
+ deal_price_rub: 6_200_000,
+ deal_price_per_m2: 138_000,
+ deal_area_m2: 45,
+ deal_rooms: 2,
+ deal_floor: 4,
+ deal_address: "Белинского, 32",
+ listing_id: 777,
+ listing_source: "avito",
+ listing_source_url: "https://avito.ru/777",
+ listing_date: "2026-01-30",
+ listing_price_rub: 6_900_000,
+ listing_price_per_m2: 153_000,
+ listing_area_m2: 45,
+ days_listing_to_deal: 44,
+ discount_pct: -10.1,
+ ...overrides,
+ };
+}
+
+function makeResponse(
+ overrides: Partial = {},
+): SalesVsListingsResponse {
+ return {
+ street: "Белинского",
+ period_months: 24,
+ window_days: 180,
+ area_tolerance: 0.15,
+ total_deals: 50,
+ deals_with_listings: 50,
+ linkage_rate_pct: 100,
+ median_discount_pct: -10.1,
+ median_discount_explanation: null,
+ data_quality: "house_linked",
+ pairs: [makePair(), makePair({ deal_id: 2 })],
+ ...overrides,
+ };
+}
+
+/** Минимальный estimate — карточка читает из него только адрес/площадь/комнаты. */
+const ESTIMATE = {
+ target_address: "Белинского, 32",
+ area_m2: 45,
+ rooms: 2,
+} as AggregatedEstimate;
+
+function stubQuery(data: SalesVsListingsResponse) {
+ vi.mocked(useSalesVsListings).mockReturnValue({
+ data,
+ isLoading: false,
+ isError: false,
+ } as UseQueryResult);
+}
+
+describe("StreetDealsCard — погашенный медианный торг", () => {
+ beforeEach(() => vi.mocked(useSalesVsListings).mockReset());
+
+ it("число прошло гейт — показываем его, объяснения нет", () => {
+ stubQuery(makeResponse());
+ render();
+ // Именно строка-шапка: тот же «−10.1%» есть и в бейджах отдельных пар,
+ // поэтому целимся в блок linkage-hint, а не в первое совпадение на странице.
+ expect(
+ screen.getByText(/сделок имеют исторический ASK/),
+ ).toHaveTextContent("медианный торг −10.1%");
+ expect(screen.queryByText(/не показываем/)).not.toBeInTheDocument();
+ });
+
+ it("число погашено — на его месте объяснение с обоими числами", () => {
+ stubQuery(
+ makeResponse({
+ median_discount_pct: null,
+ median_discount_explanation: GATED_EXPLANATION,
+ }),
+ );
+ render();
+
+ expect(screen.getByText(GATED_EXPLANATION)).toBeInTheDocument();
+ // Ни строки «· медианный торг», ни подставленного вместо неё нуля.
+ expect(screen.queryByText(/· медианный торг/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/[−+]?0\.0%/)).not.toBeInTheDocument();
+ // Остальная карточка на месте: гаснет одно число, а не виджет целиком.
+ expect(screen.getByText("Сделок за 24 мес")).toBeInTheDocument();
+ expect(screen.getByText("100%")).toBeInTheDocument();
+ });
+});
+
+describe("mapHistory (/v2) — погашенный медианный торг", () => {
+ it("объяснение попадает в note, числа торга там нет", () => {
+ const { dkpHeader } = mapHistory(
+ null,
+ null,
+ makeResponse({
+ median_discount_pct: null,
+ median_discount_explanation: GATED_EXPLANATION,
+ }),
+ );
+ expect(dkpHeader.note).toContain(GATED_EXPLANATION);
+ expect(dkpHeader.note).not.toContain("медианный торг ");
+ });
+
+ it("прошедшее гейт число попадает в note вместо объяснения", () => {
+ const { dkpHeader } = mapHistory(null, null, makeResponse());
+ // Разделитель дробной части задаёт локаль ru-RU — знак и цифры пиним, а
+ // запятую/точку нет, иначе тест ловил бы сборку ICU, а не поведение гейта.
+ expect(dkpHeader.note).toMatch(/медианный торг −10[.,]1%/);
+ expect(dkpHeader.note).not.toContain("не показываем");
+ });
+});
diff --git a/tradein-mvp/frontend/vitest-env.d.ts b/tradein-mvp/frontend/vitest-env.d.ts
new file mode 100644
index 00000000..edfa8b53
--- /dev/null
+++ b/tradein-mvp/frontend/vitest-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/tradein-mvp/frontend/vitest.config.ts b/tradein-mvp/frontend/vitest.config.ts
new file mode 100644
index 00000000..0a1dea5b
--- /dev/null
+++ b/tradein-mvp/frontend/vitest.config.ts
@@ -0,0 +1,21 @@
+import { fileURLToPath } from "node:url";
+
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ // Mirror tsconfig "paths": { "@/*": ["./src/*"] } so test imports like
+ // `@/lib/wkt` resolve the same way they do in the Next.js build.
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ },
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./vitest.setup.ts"],
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ },
+});
diff --git a/tradein-mvp/frontend/vitest.setup.ts b/tradein-mvp/frontend/vitest.setup.ts
new file mode 100644
index 00000000..8272da97
--- /dev/null
+++ b/tradein-mvp/frontend/vitest.setup.ts
@@ -0,0 +1,3 @@
+// Registers jest-dom matchers (toBeInTheDocument, toHaveStyle, …) on Vitest's
+// `expect`. Imported via vitest.config.ts `setupFiles`.
+import "@testing-library/jest-dom/vitest";
From 5046ac7b4ea91bc48100172f459bd479fdab7acd Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Fri, 7 Aug 2026 08:06:25 +0000
Subject: [PATCH 014/124] =?UTF-8?q?fix(tradein/cian):=20=D0=BF=D0=BE=D0=BA?=
=?UTF-8?q?=D0=B0=D0=B7=D0=B0=D1=82=D1=8C,=20=D0=A7=D0=A2=D0=9E=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=B8=D1=88=D0=BB=D0=BE=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?=
=?UTF-8?q?=D0=BE=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F?=
=?UTF-8?q?=20=D0=96=D0=9A-=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?=
=?UTF-8?q?=D1=8B,=20=D0=B8=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=BE=20?=
=?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD?=
=?UTF-8?q?=D1=83=D0=BB=D0=B5=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3?=
=?UTF-8?q?=D0=BE=D0=BD=20(#2767)=20(#2768)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../app/tasks/newbuilding_enrich_backfill.py | 52 ++++-
.../tests/test_2725_heartbeat_in_batch.py | 3 +
.../tests/test_2767_newbuilding_parse_miss.py | 189 ++++++++++++++++++
.../scraper_kit/providers/cian/newbuilding.py | 33 ++-
4 files changed, 272 insertions(+), 5 deletions(-)
create mode 100644 tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
index e46fe9d9..93dd3113 100644
--- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
+++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
@@ -119,6 +119,27 @@ class NewbuildingEnrichBackfillResult:
def to_dict(self) -> dict[str, int]:
return {f.name: int(getattr(self, f.name)) for f in fields(self)}
+ def to_backfill_counters(self) -> dict[str, int]:
+ """to_dict() + ключи, которые читает общий финализатор (#2695, #2767).
+
+ runs.mark_backfill_finished смотрит на attempted / enriched / failed; у этой
+ задачи те же величины называются иначе, поэтому через общий финализатор она не
+ проходила и ЛЮБОЙ прогон закрывался mark_done — включая 25 попыток с нулём
+ обогащений восемь суток подряд.
+
+ `attempted` — дома, до которых дошли руки: пропущенный уже-обогащённый домом
+ попыткой не был, иначе прогон, которому просто нечего делать, выглядел бы
+ отказом. `blocked` НЕ заводим намеренно: задача блоки источника не различает,
+ а нулевой прогон без доказанных блоков финализатор помечает 'failed' — наш
+ тракт, а не «виновата площадка» (#2764: диагноз ставится по доказательству).
+ """
+ return {
+ **self.to_dict(),
+ "attempted": self.processed - self.skipped_already_enriched,
+ "enriched": self.succeeded,
+ "failed": self.failed_resolve + self.failed_fetch + self.failed_save,
+ }
+
# SQL: anchor on the canonical cian_newbuilding link, require a house we can FETCH, and
# (unless force) skip houses already enriched.
@@ -673,7 +694,9 @@ async def run_newbuilding_enrich(
Thin scheduler wrapper around backfill_newbuilding_enrichment() — mirrors
tasks/yandex_address_backfill.run_yandex_address_backfill: emit a heartbeat before the
- batch, delegate to the proven backfill, then mark the scrape_run done/failed.
+ batch, delegate to the proven backfill, then finalise the scrape_run through the
+ SHARED runs.mark_backfill_finished (#2695) — ноль обогащений при ненулевых попытках
+ закрывается 'failed', а не 'done' (#2767; счётчики маппит to_backfill_counters).
Idempotency is inherited from backfill_newbuilding_enrichment(): with force=False its
SELECT excludes houses that already have BOTH price_dynamics AND reliability rows, and
@@ -727,10 +750,31 @@ async def run_newbuilding_enrich(
on_progress=_heartbeat,
)
- counters = result.to_dict()
- runs_mod.mark_done(db, run_id, counters)
+ # Честный финал через ОБЩИЙ финализатор (#2695): ноль обогащений при ненулевых
+ # попытках больше не 'done'. fail_hint называет ЭТАП, на котором чаще всего
+ # отказывало (resolve / fetch / save) — «failed=25» на этот вопрос не отвечает.
+ counters = result.to_backfill_counters()
+ stage, stage_n = max(
+ (
+ ("resolve", result.failed_resolve),
+ ("fetch", result.failed_fetch),
+ ("save", result.failed_save),
+ ),
+ key=lambda kv: kv[1],
+ )
+ runs_mod.mark_backfill_finished(
+ db,
+ run_id,
+ counters,
+ source="newbuilding_enrich",
+ fail_hint=(
+ f"чаще всего отказ на этапе {stage} ({stage_n} из {counters['attempted']})"
+ if stage_n
+ else None
+ ),
+ )
logger.info(
- "scheduler: newbuilding_enrich run_id=%d done — processed=%d ok=%d skip=%d "
+ "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",
run_id,
diff --git a/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py b/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py
index a0450aa2..5dfc15c4 100644
--- a/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py
+++ b/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py
@@ -213,6 +213,9 @@ async def test_newbuilding_enrich_passes_progress_callback() -> None:
update_heartbeat=lambda db, run_id, counters: beats.append(counters),
mark_done=MagicMock(),
mark_failed=MagicMock(),
+ # Финал прогона ушёл в общий mark_backfill_finished (#2767) — без него подмена
+ # runs_mod роняет AttributeError и тест меряет не то, что проверяет.
+ mark_backfill_finished=MagicMock(),
)
with (
diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
new file mode 100644
index 00000000..3ca02ab6
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
@@ -0,0 +1,189 @@
+"""#2767 — сделать причину отказа newbuilding_enrich видимой.
+
+Восемь суток подряд прогон делал 25 попыток, обогащал ноль записей и закрывался
+статусом 'done'. Различить «страница пришла целиком, изменилась разметка» и
+«пришла защитная заглушка» было нельзя: в логе не было ни размера ответа, ни
+признаков защиты, а статус утверждал успех.
+
+Две проверки, обе падают на коде до правки:
+ 1. строка отказа разбора несёт размер полученной страницы и найденные маркеры;
+ 2. прогон с ненулевыми попытками и нулём обогащений финализируется через общий
+ runs.mark_backfill_finished (#2695), а не через mark_done.
+
+Ни сети, ни БД: BrowserFetcher подменён спаем, финализаторы — заглушками.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+# DATABASE_URL required by config before any app import.
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+# WeasyPrint stub — not installed in CI without GTK.
+sys.modules.setdefault("weasyprint", MagicMock())
+
+import pytest # noqa: E402
+from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
+ _describe_parse_miss,
+ fetch_newbuilding,
+)
+
+from app.tasks import newbuilding_enrich_backfill as task_mod # noqa: E402
+from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
+ NewbuildingEnrichBackfillResult,
+ run_newbuilding_enrich,
+)
+
+# ---------------------------------------------------------------------------
+# 1. Размер страницы + маркеры защиты в строке отказа разбора.
+# ---------------------------------------------------------------------------
+
+
+def test_describe_parse_miss_reports_size_and_no_markers() -> None:
+ """Целая страница без маркеров: размер есть, маркеров нет → «изменилась разметка»."""
+ html = "" + "x" * 1_170_000 + ""
+ described = _describe_parse_miss(html)
+ assert f"html_len={len(html)}" in described
+ assert "antibot_markers=none" in described
+
+
+def test_describe_parse_miss_names_found_markers() -> None:
+ """Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного."""
+ described = _describe_parse_miss("
Вы не робот?
")
+ assert "captcha" in described
+ assert "вы не робот" in described
+ assert "html_len=" in described
+
+
+def _spy_browser_fetcher(html: str) -> MagicMock:
+ """BrowserFetcher-подмена: async-CM, чей .fetch() отдаёт заданный html."""
+ fetcher = MagicMock()
+ fetcher.fetch = AsyncMock(return_value=html)
+ fetcher.__aenter__ = AsyncMock(return_value=fetcher)
+ fetcher.__aexit__ = AsyncMock(return_value=None)
+ return MagicMock(return_value=fetcher)
+
+
+@pytest.mark.asyncio
+async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None:
+ """Отказ разбора обязан писать РАЗМЕР полученной страницы — то самое число, которое
+ разделяет «изменилась разметка» и «защитная заглушка» (иначе log бесполезен)."""
+ shell = "Доступ ограничен" # короткая заглушка
+ monkeypatch.setattr(
+ "scraper_kit.providers.cian.newbuilding.BrowserFetcher", _spy_browser_fetcher(shell)
+ )
+
+ with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
+ result = await fetch_newbuilding(
+ "https://zhk-tihiy-centr-ekb-i.cian.ru",
+ config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
+ )
+
+ assert result is None
+ message = "\n".join(r.getMessage() for r in caplog.records)
+ assert "initialState extraction failed" in message
+ assert f"html_len={len(shell)}" in message
+ assert "доступ ограничен" in message
+
+
+# ---------------------------------------------------------------------------
+# 2. Честный статус: ноль обогащений при ненулевых попытках ≠ 'done'.
+# ---------------------------------------------------------------------------
+
+
+def _stub_finalisers(monkeypatch: pytest.MonkeyPatch) -> dict:
+ """Заглушить lifecycle-функции; вернуть журнал вызовов финализаторов."""
+ calls: dict = {}
+ monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None)
+ monkeypatch.setattr(
+ task_mod.runs_mod,
+ "mark_done",
+ lambda _db, run_id, counters: calls.setdefault("mark_done", dict(counters)),
+ )
+ monkeypatch.setattr(
+ task_mod.runs_mod,
+ "mark_failed",
+ lambda _db, run_id, err, counters: calls.setdefault("mark_failed", err),
+ )
+ monkeypatch.setattr(
+ task_mod.runs_mod,
+ "mark_backfill_finished",
+ lambda _db, run_id, counters, **kw: calls.setdefault(
+ "finished", {"counters": dict(counters), **kw}
+ ),
+ )
+ return calls
+
+
+def _stub_backfill(
+ monkeypatch: pytest.MonkeyPatch, result: NewbuildingEnrichBackfillResult
+) -> None:
+ async def _fake(_db, **_kwargs):
+ return result
+
+ monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake)
+
+
+@pytest.mark.asyncio
+async def test_zero_enrichment_run_goes_through_shared_finaliser(monkeypatch) -> None:
+ """Прод-картина: 25 попыток, 0 успехов, 25 отказов fetch. Финал — общий
+ mark_backfill_finished (который такой прогон помечает 'failed'), НЕ mark_done."""
+ _stub_backfill(
+ monkeypatch,
+ NewbuildingEnrichBackfillResult(processed=25, succeeded=0, failed_fetch=25),
+ )
+ calls = _stub_finalisers(monkeypatch)
+
+ await run_newbuilding_enrich(object(), run_id=42, params={}) # type: ignore[arg-type]
+
+ assert "mark_done" not in calls # ← падает на старом коде
+ finished = calls["finished"]
+ assert finished["source"] == "newbuilding_enrich"
+ assert finished["counters"]["attempted"] == 25
+ assert finished["counters"]["enriched"] == 0
+ assert finished["counters"]["failed"] == 25
+ # Диагноза «виновата площадка» здесь нет: блоки не считаются вовсе (#2764).
+ assert finished["counters"].get("blocked") is None
+ assert "fetch" in finished["fail_hint"]
+
+
+@pytest.mark.asyncio
+async def test_all_skipped_run_reports_zero_attempts(monkeypatch) -> None:
+ """Все выбранные дома уже обогащены → attempted=0: честная пустота, а не отказ
+ (общий финализатор такой прогон закрывает 'done')."""
+ _stub_backfill(
+ monkeypatch,
+ NewbuildingEnrichBackfillResult(processed=25, skipped_already_enriched=25),
+ )
+ calls = _stub_finalisers(monkeypatch)
+
+ await run_newbuilding_enrich(object(), run_id=43, params={}) # type: ignore[arg-type]
+
+ assert calls["finished"]["counters"]["attempted"] == 0
+ assert calls["finished"]["fail_hint"] is None
+
+
+@pytest.mark.asyncio
+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
+ ),
+ )
+ calls = _stub_finalisers(monkeypatch)
+
+ await run_newbuilding_enrich(object(), run_id=44, params={}) # type: ignore[arg-type]
+
+ counters = calls["finished"]["counters"]
+ assert counters["attempted"] == 10
+ assert counters["enriched"] == 3
+ assert counters["failed"] == 7
+ assert counters["price_dynamics_rows"] == 7 # исходные счётчики на месте
+ assert counters["succeeded"] == 3
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 00f8a5bd..566b2a13 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
@@ -70,6 +70,33 @@ _CATPH_NEWBUILDING_SERP = (
"&offer_type=flat&object_type[0]=2&newobject[0]={nb_id}"
)
+# Подписи защитных страниц — подстроки, которых на нормальной ЖК-карточке нет (#2767).
+# Список НАМЕРЕННО короткий: он не выносит диагноз, а называет найденное.
+_ANTIBOT_MARKERS = (
+ "captcha", # покрывает и recaptcha, и smartcaptcha
+ "qrator",
+ "ddos-guard",
+ "cf-chl", # Cloudflare challenge
+ "доступ ограничен",
+ "вы не робот",
+)
+
+
+def _describe_parse_miss(html: str) -> str:
+ """Факты о полученной странице для строки отказа разбора: размер + найденные маркеры.
+
+ НЕ диагноз (#2767): перечисляет найденное, причину не называет. Размер разделяет
+ две гипотезы одним числом — здоровая ЖК-карточка весит 1.17 МБ и содержит
+ initialState (verified live 2026-06-15), поэтому ~1 МБ без состояния читается как
+ «страница пришла целиком, изменилась разметка», а десятки килобайт — как защитная
+ заглушка (страница без состояния = подпись антибота, #972). До этой строки в логе
+ не было ни байтов, ни признаков защиты: восемь суток по 25 предупреждений за
+ прогон не отвечали на единственный вопрос, ради которого их читают.
+ """
+ lowered = html.lower()
+ found = [m for m in _ANTIBOT_MARKERS if m in lowered]
+ return f"html_len={len(html)} antibot_markers={','.join(found) if found else 'none'}"
+
@dataclass
class NewbuildingEnrichment:
@@ -163,7 +190,11 @@ async def fetch_newbuilding(
nb_state = extract_state(html, mfe=mfe, key="initialState")
if nb_state is None:
- logger.warning("Cian newbuilding %s: initialState extraction failed", zhk_url)
+ logger.warning(
+ "Cian newbuilding %s: initialState extraction failed — %s",
+ zhk_url,
+ _describe_parse_miss(html),
+ )
return None
# Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4)
From 339713c1aa238dbdce50ab14fe75e57e6fbef104 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Fri, 7 Aug 2026 08:44:48 +0000
Subject: [PATCH 015/124] =?UTF-8?q?fix(tradein/yandex):=20=D0=BF=D1=80?=
=?UTF-8?q?=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D1=8B=20=D1=80=D0=B5=D0=B6?=
=?UTF-8?q?=D1=83=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20=D1=80=D0=B0=D0=B7?=
=?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D0=B5=D0=BB=D1=8E=20=D1=82=D1=8B?=
=?UTF-8?q?=D1=81=D1=8F=D1=87=20+=20=D0=A6=D0=B8=D0=B0=D0=BD=20=D0=BC?=
=?UTF-8?q?=D0=BE=D0=BB=D1=87=D0=B8=D1=82=20=D0=BE=20=D0=BD=D0=B5=D0=BF?=
=?UTF-8?q?=D0=BE=D0=BD=D1=8F=D1=82=D0=BE=D0=B9=20=D1=84=D1=80=D0=B0=D0=B7?=
=?UTF-8?q?=D0=B5=20(#2669)=20(#2773)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../backend/tests/test_cian_views_parse.py | 31 +++++++++++++++
.../backend/tests/test_yandex_detail.py | 39 +++++++++++++++++++
.../src/scraper_kit/providers/cian/detail.py | 16 ++++++++
.../scraper_kit/providers/yandex/detail.py | 5 ++-
.../src/scraper_kit/yandex_helpers.py | 8 +++-
5 files changed, 96 insertions(+), 3 deletions(-)
diff --git a/tradein-mvp/backend/tests/test_cian_views_parse.py b/tradein-mvp/backend/tests/test_cian_views_parse.py
index 55db5237..44d0488d 100644
--- a/tradein-mvp/backend/tests/test_cian_views_parse.py
+++ b/tradein-mvp/backend/tests/test_cian_views_parse.py
@@ -13,6 +13,7 @@
from __future__ import annotations
+import logging
import os
from unittest.mock import AsyncMock, MagicMock
@@ -52,12 +53,42 @@ def test_fixture_still_carries_the_phrase() -> None:
("", (None, None)),
(None, (None, None)),
("просмотров нет", (None, None)), # без цифр — пусто, а не 0
+ ("нет данных", (None, None)), # ответ площадки «нечего показать»
],
)
def test_parse_views_forms(phrase: str | None, expected: tuple[int | None, int | None]) -> None:
assert _parse_views(phrase) == expected
+def test_unparsed_phrase_is_logged_with_itself(caplog: pytest.LogCaptureFixture) -> None:
+ """Провал разбора обязан быть отличим от «Циан не прислал» (эпик #2674).
+
+ Прошлая пропажа держалась месяцами ровно потому, что в журнале не было ни
+ строки: пустая колонка одинаково выглядела и при сломанном разборе, и при
+ молчании площадки. Поэтому в warning уходит САМА фраза.
+ """
+ with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
+ assert _parse_views("нет данных") == (None, None)
+
+ assert "нет данных" in caplog.text, "неразобранная фраза не видна в журнале"
+
+
+def test_absent_phrase_is_not_logged(caplog: pytest.LogCaptureFixture) -> None:
+ """Циан не прислал ключ вовсе — это не поломка разбора, шуметь не о чем."""
+ with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
+ assert _parse_views(None) == (None, None)
+
+ assert caplog.text == ""
+
+
+def test_no_warning_when_today_tail_absent(caplog: pytest.LogCaptureFixture) -> None:
+ """«0 просмотров» без хвоста «за сегодня» — штатная форма, не повод для warning."""
+ with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
+ assert _parse_views("0 просмотров") == (0, None)
+
+ assert caplog.text == ""
+
+
async def test_fetch_detail_fills_views_from_real_saved_page() -> None:
"""Настоящая сохранённая страница → views_total/views_today непусты (#2669)."""
fetcher = MagicMock()
diff --git a/tradein-mvp/backend/tests/test_yandex_detail.py b/tradein-mvp/backend/tests/test_yandex_detail.py
index c3431aa5..f481529d 100644
--- a/tradein-mvp/backend/tests/test_yandex_detail.py
+++ b/tradein-mvp/backend/tests/test_yandex_detail.py
@@ -385,3 +385,42 @@ class TestHelpers:
result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL)
assert result is not None
assert result.metro_walk_min == 7
+
+
+# ---------------------------------------------------------------------------
+# #2669 — просмотры с разделителем тысяч
+# ---------------------------------------------------------------------------
+# Форма взята дословно из сохранённого ответа Яндекса
+# tests/fixtures/yandex_offer_3402418396407468801.html (сверено 2026-08-07):
+#
-секций с годами разбираются в одном проходе;
+# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ,
+# поэтому «2.» встречается дважды — и site_name всё равно обязан быть
+# уникальным (номер сам по себе ключом не является);
+# • блоки лежат внутри контейнера
— это ветка `_extract_krt_section`
+# через `find_parent`, которой одиночные блоки выше не касаются вовсе.
+#
+# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778):
+# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора
+# реального документа, синтетической страницей оно не заменяется. На проде
+# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но
+# автоматической проверки этого больше нет — она вернётся, когда в репозитории
+# появится образец страницы.
+_MULTISITE_PAGE = (
+ "
- )}
{/* Recalculation indicator — shown while re-analyze is in-flight after
weights change (data already loaded, pendingWeightsChange set). */}
diff --git a/frontend/src/components/site-finder/WeightProfilePanel.tsx b/frontend/src/components/site-finder/WeightProfilePanel.tsx
index 887cd1d8..60fcbe5b 100644
--- a/frontend/src/components/site-finder/WeightProfilePanel.tsx
+++ b/frontend/src/components/site-finder/WeightProfilePanel.tsx
@@ -3,6 +3,7 @@
import { useCallback, useState } from "react";
import { SectionLabel } from "@/components/ui/SectionLabel";
+import { useMe } from "@/lib/useMe";
import {
POI_CATEGORIES,
POI_DEFAULT_WEIGHTS,
@@ -29,13 +30,6 @@ interface Props {
weights: Record,
profileId: number | null,
) => void;
- /**
- * If provided, enables save/load from DB.
- * Must be non-empty for CRUD functionality.
- */
- userId?: string;
- /** Admin token for CRUD API calls. */
- adminToken?: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -66,12 +60,7 @@ function weightsEqual(
// ── Component ─────────────────────────────────────────────────────────────────
-export function WeightProfilePanel({
- currentWeights,
- onWeightsChange,
- userId,
- adminToken,
-}: Props) {
+export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
const [open, setOpen] = useState(false);
// Local draft weights — editable before "Применить"
@@ -89,11 +78,19 @@ export function WeightProfilePanel({
const [saveName, setSaveName] = useState("");
const [saveDefault, setSaveDefault] = useState(false);
- // Profiles query (only when userId + adminToken provided)
- const canUseCrud = !!userId && !!adminToken;
- const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
+ // Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили
+ // руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было
+ // взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в
+ // #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут
+ // RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется).
+ // В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто
+ // бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен.
+ const { data: me } = useMe();
+ const userId = me?.username ?? "";
+ const canUseCrud = !!userId;
+ const profilesQuery = useWeightProfiles(userId);
- const createMutation = useCreateProfile(adminToken ?? "");
+ const createMutation = useCreateProfile();
// ── Handlers ────────────────────────────────────────────────────────────────
@@ -117,7 +114,7 @@ export function WeightProfilePanel({
}
const handleSaveProfile = useCallback(async () => {
- if (!canUseCrud || !userId || !adminToken) {
+ if (!canUseCrud || !userId) {
setShowSaveDialog(false);
return;
}
@@ -136,15 +133,7 @@ export function WeightProfilePanel({
} catch {
// Error visible through createMutation.error
}
- }, [
- canUseCrud,
- userId,
- adminToken,
- saveName,
- draft,
- saveDefault,
- createMutation,
- ]);
+ }, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]);
// ── Derived ─────────────────────────────────────────────────────────────────
@@ -242,6 +231,7 @@ export function WeightProfilePanel({
@@ -278,10 +268,13 @@ export function WeightProfilePanel({
)}
- {/* Hint when no crud */}
+ {/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
+ Caddy basic_auth) — остаётся для dev-запуска без прокси, где
+ /api/v1/me отвечает 401. Текст называет причину, а не действие:
+ вводить тут больше нечего. */}
{!canUseCrud && (
- Укажите User ID и Admin Token для сохранения профилей.
+ Пользователь не определён — сохранение профилей недоступно.
)}
@@ -362,6 +355,9 @@ export function WeightProfilePanel({
}}
>
Новый профиль
+
+ Сохранится для пользователя {userId} — другие его не увидят.
+
();
+
+function jsonResponse(body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+const PROFILE = {
+ id: 1,
+ user_id: "admin",
+ profile_name: "Мой профиль",
+ weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 },
+ is_default: true,
+ description: null,
+ created_at: "2026-05-15T05:30:51Z",
+ updated_at: "2026-05-15T05:30:51Z",
+};
+
+function renderPanel() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+ {}}
+ />
+ ,
+ );
+}
+
+/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
+async function openPanel() {
+ const { default: userEvent } = await import("@testing-library/user-event");
+ await userEvent.setup().click(screen.getByText("POI Веса"));
+}
+
+beforeEach(() => {
+ fetchMock.mockReset();
+ fetchMock.mockImplementation(async (input) => {
+ const url = typeof input === "string" ? input : String(input);
+ if (url.includes("/api/v1/me")) {
+ return jsonResponse({
+ username: "admin",
+ role: "admin",
+ allowed_paths: ["/**"],
+ deny_paths: [],
+ });
+ }
+ if (url.includes("/weight-profiles")) {
+ return jsonResponse([PROFILE]);
+ }
+ throw new Error(`unexpected fetch: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+});
+
+describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => {
+ it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => {
+ renderPanel();
+ await openPanel();
+
+ // Профили грузятся по user_id из /me, без ручного ввода и без токена.
+ await waitFor(() =>
+ expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
+ );
+ expect(screen.getByText("Профиль:")).toBeTruthy();
+ });
+
+ it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => {
+ renderPanel();
+ await openPanel();
+
+ await waitFor(() =>
+ expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
+ );
+ expect(screen.queryByText(/Admin Token/i)).toBeNull();
+ expect(screen.queryByText(/Укажите User ID/i)).toBeNull();
+ });
+
+ it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => {
+ renderPanel();
+ await openPanel();
+
+ await waitFor(() => {
+ const crudCall = fetchMock.mock.calls.find(([input]) =>
+ String(input).includes("/weight-profiles"),
+ );
+ expect(crudCall).toBeTruthy();
+ const headers = new Headers(
+ (crudCall?.[1] as RequestInit | undefined)?.headers,
+ );
+ expect(headers.has("X-Admin-Token")).toBe(false);
+ });
+ });
+});
+
+describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => {
+ const weights = { ...POI_DEFAULT_WEIGHTS };
+
+ it("выбранный профиль уходит вместе с profileUserId", () => {
+ expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({
+ profileId: 7,
+ profileUserId: "admin",
+ });
+ });
+
+ it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => {
+ const options = buildAnalyzeOptions(weights, 7, "");
+ expect(options.profileId).toBeUndefined();
+ expect(options.weights).toEqual(weights);
+ });
+
+ it("без профиля уходят inline-веса и владелец для default-профиля", () => {
+ expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({
+ profileUserId: "admin",
+ weights,
+ });
+ });
+});
diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts
index 1ffd3a7e..745ccdb1 100644
--- a/frontend/src/hooks/useSiteAnalysis.ts
+++ b/frontend/src/hooks/useSiteAnalysis.ts
@@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
// /fetch-status every 2s, re-triggers analyze когда status=ready.
export type FetchStatus =
- | "ready"
- | "fetching"
- | "failed"
- | "not_in_nspd"
- | "invalid_format";
+ "ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
export interface FetchStatusResponse {
status: FetchStatus;
@@ -52,6 +48,34 @@ export interface AnalyzeOptions {
weights?: Record | null;
}
+/**
+ * Собрать options для POST /analyze из состояния панели весов POI.
+ *
+ * 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
+ * стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
+ * profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
+ * not None` не выполняется, и веса тихо падают на системные. Ответ при этом
+ * рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
+ * #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из
+ * профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
+ *
+ * Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline
+ * `weights`. Они всегда равны ползункам панели, так что расхождения нет.
+ */
+export function buildAnalyzeOptions(
+ weights: Record,
+ profileId: number | null,
+ profileUserId: string,
+): AnalyzeOptions {
+ if (profileId != null && profileUserId) {
+ return { profileId, profileUserId };
+ }
+ if (profileUserId) {
+ return { profileUserId, weights };
+ }
+ return { weights };
+}
+
/**
* Custom hook для analyze flow с graceful on-demand fetch fallback.
*
diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts
index f2619dfa..4aa28bd7 100644
--- a/frontend/src/lib/api/weightProfiles.ts
+++ b/frontend/src/lib/api/weightProfiles.ts
@@ -91,35 +91,37 @@ export const POI_WEIGHT_MAX = 3;
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
-function profilesHeaders(adminToken: string): HeadersInit {
- return { "X-Admin-Token": adminToken };
-}
+// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
+// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
+// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
+// с корректным токеном, с мусорным и без заголовка вовсе.
+//
+// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
+// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
+// (подставленный клиентом X-Authenticated-User туда же не проходит);
+// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
// ── Hooks ─────────────────────────────────────────────────────────────────────
/** List all weight profiles for a given user_id. */
-export function useWeightProfiles(userId: string, adminToken: string) {
+export function useWeightProfiles(userId: string) {
return useQuery({
queryKey: ["weight-profiles", userId],
queryFn: () =>
apiFetch(
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
- {
- headers: profilesHeaders(adminToken),
- },
),
- enabled: !!userId && !!adminToken,
+ enabled: !!userId,
});
}
/** Create a new weight profile. Invalidates the list query on success. */
-export function useCreateProfile(adminToken: string) {
+export function useCreateProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload) =>
apiFetch(BASE_PATH, {
method: "POST",
- headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
}),
onSuccess: (_, variables) => {
@@ -131,11 +133,7 @@ export function useCreateProfile(adminToken: string) {
}
/** Update an existing weight profile by id. */
-export function useUpdateProfile(
- userId: string,
- profileId: number,
- adminToken: string,
-) {
+export function useUpdateProfile(userId: string, profileId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload) =>
@@ -143,7 +141,6 @@ export function useUpdateProfile(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "PUT",
- headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
},
),
@@ -154,16 +151,13 @@ export function useUpdateProfile(
}
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
-export function useDeleteProfile(userId: string, adminToken: string) {
+export function useDeleteProfile(userId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (profileId) =>
apiFetch(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
- {
- method: "DELETE",
- headers: profilesHeaders(adminToken),
- },
+ { method: "DELETE" },
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
From e4680082ea2da3e8c4c43f2d0c416bb22b63aa79 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Fri, 7 Aug 2026 10:35:15 +0000
Subject: [PATCH 026/124] =?UTF-8?q?test(ci):=20=D1=80=D0=B0=D0=B7=D0=B2?=
=?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20=C2=AB=D0=BD=D0=B0=D0=B3=D1=80=D1=83?=
=?UTF-8?q?=D0=B7=D0=BA=D1=83=20=D0=BD=D0=B5=20=D1=81=D0=BE=D0=B7=D0=B4?=
=?UTF-8?q?=D0=B0=D1=82=D1=8C=C2=BB=20=D0=B8=20=C2=AB=D0=B7=D0=B0=D1=89?=
=?UTF-8?q?=D0=B8=D1=82=D0=B0=20=D1=81=D0=BB=D0=BE=D0=BC=D0=B0=D0=BD=D0=B0?=
=?UTF-8?q?=C2=BB=20=D0=B2=20=D0=B4=D0=B2=D1=83=D1=85=20=D1=84=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BF=D0=B0=D1=8E=D1=89=D0=B8=D1=85=20=D0=B3=D0=B5=D0=B9=D1=82?=
=?UTF-8?q?=D0=B0=D1=85=20(#2783)=20(#2789)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/tests/services/test_weather_cache.py | 66 +++++++++++++++++---
tradein-mvp/backend/tests/test_auth_api.py | 30 +++++++--
2 files changed, 80 insertions(+), 16 deletions(-)
diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py
index f04c2322..c5cab6f1 100644
--- a/backend/tests/services/test_weather_cache.py
+++ b/backend/tests/services/test_weather_cache.py
@@ -10,8 +10,10 @@
DNS-fail повторяет timeout на каждый analyze.
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
раздельные таблицы внутри модуля).
- 4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start →
- ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store).
+ 4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на
+ поток, все получают одно и то же значение, и шторм заканчивается сложившимся
+ кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно
+ (#1370, см. сам тест).
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
@@ -23,6 +25,7 @@ from __future__ import annotations
import os
import threading
+import time
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
@@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate:
class TestConcurrencySafe:
- def test_single_flight_cold_start_one_network_call(self) -> None:
- """16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов."""
- # GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock.
+ def test_cold_start_storm_bounded_and_cache_converges(self) -> None:
+ """16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток,
+ все получают одно и то же значение, и после шторма кэш отвечает без сети.
+
+ ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это
+ было требование, которого код НЕ выполняет и выполнять не собирается:
+ сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze
+ сериализуются на время httpx-вызова даже для разных координат), а рядом с
+ ним написано, что cold-start на один ключ «может породить несколько
+ параллельных запросов… приемлемо». Тест зеленел не потому, что защита
+ работает, а потому что при GIL первый поток обычно успевал сложить
+ результат раньше остальных.
+
+ Замер 2026-08-07, 200 штормов подряд: при дефолтном
+ `sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые
+ ~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов,
+ было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда
+ потоки реально чередуются, больше одного вызова дали 197 штормов из 200,
+ и в 173 из них вызовов было все 16. То есть утверждение ложно почти
+ всегда, когда гонка вообще случается, — чинить надо было тест.
+
+ Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ
+ стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние
+ запросы бывают только на cold-start одного ключа и они идемпотентны.
+ Понадобится — это отдельная задача с отдельным обоснованием, а не
+ побочный эффект правки теста.
+
+ `time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти
+ промах кэша до первой записи. Так тест мерит худший случай той самой
+ уступки, а не везение планировщика.
+ """
start_barrier = threading.Barrier(16)
get_call_count = 0
get_lock = threading.Lock()
@@ -253,8 +284,7 @@ class TestConcurrencySafe:
nonlocal get_call_count
with get_lock:
get_call_count += 1
- # Микро-задержка — окно для других потоков добраться до lock'а.
- # Не делаем sleep большим, чтобы тест не висел.
+ time.sleep(0.05) # окно, в котором остальные потоки видят промах
return _make_httpx_response(_make_forecast_response())
client_ctx = MagicMock()
@@ -276,11 +306,27 @@ class TestConcurrencySafe:
t.start()
for t in threads:
t.join()
+ storm_calls = get_call_count
+ # Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что
+ # поход в сеть был бы виден счётчиком, а не отказом коннекта.
+ after_storm = weather_cache.get_weather_cached(56.84, 60.59)
assert len(results) == 16
- assert all(r is not None for r in results)
- # Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов.
- assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}"
+ assert results[0] is not None
+ assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения"
+ # Потолок — число участников: в сеть идут только промахнувшиеся, по разу
+ # каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная
+ # запись в кэш); меньше единицы невозможно, кэш был пуст.
+ assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках"
+ # Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз:
+ # следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен
+ # снятого single-flight — без этого уступка превращается в дыру.
+ assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)]
+ assert after_storm == results[0]
+ assert get_call_count == storm_calls, (
+ f"после шторма кэш обязан отвечать без сети, а вызовов стало "
+ f"{get_call_count} против {storm_calls}"
+ )
# ──────────────────────────────────────────────────────────────────────────────
diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py
index 17b0930e..9cd7b5cd 100644
--- a/tradein-mvp/backend/tests/test_auth_api.py
+++ b/tradein-mvp/backend/tests/test_auth_api.py
@@ -839,13 +839,31 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
len(probe_latencies) >= 10
), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
- # 2. ТЕМП ограничен. Флуд предлагал тысячи попыток в секунду — до bcrypt их
- # доехало не больше потолка (запас ×1.5 на планировщик).
- assert len(codes) > connections, (
- "флуд не состоялся: на каждое соединение вышло не больше одного ответа — "
- "мерить потолок не на чем"
+ # 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
+ # потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик).
+ max_attempts_per_s = ceiling_per_s * 1.5
+ offered_per_s = len(codes) / elapsed
+
+ # ПРЕДУСЛОВИЕ, и оно отделено от вердикта намеренно: «нагрузку создать не
+ # удалось» и «потолок не работает» — разные новости, и красный обязан их
+ # различать. Порог здесь ТОТ ЖЕ, с которым сверяется вердикт ниже, и это не
+ # совпадение: пока флуд предлагает меньше, следующее утверждение зелено даже
+ # на системе вовсе без потолка, то есть измерения нет.
+ #
+ # Раньше условием было `len(codes) > connections` — «каждое соединение
+ # успело сходить хотя бы дважды за секунду». Это мерило скорости РАННЕРА, а
+ # не нагрузки: на занятом первый круг из ста запросов сам съедал всю секунду,
+ # и сторож краснел неотличимо от настоящей поломки потолка (замер 2026-08-07:
+ # свободная машина — 0 падений из 20, ~800 ответов/с; под load average ~185 —
+ # 10 падений из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с
+ # против порога 30/с, то есть мерить было на чем).
+ assert offered_per_s > max_attempts_per_s, (
+ f"нагрузку создать не удалось: флуд предложил {offered_per_s:.0f} запросов/с "
+ f"({len(codes)} за {elapsed:.2f}с) — не больше порога следующей проверки "
+ f"({max_attempts_per_s:.0f}/с), она прошла бы и без потолка. Это «раннер не "
+ f"потянул», НЕ «потолок сломан»"
)
- assert attempts_per_s <= ceiling_per_s * 1.5, (
+ assert attempts_per_s <= max_attempts_per_s, (
f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с "
f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает"
)
From 29f10002289324bba1147e3a9a5c5614d42232ca Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Fri, 7 Aug 2026 11:01:31 +0000
Subject: [PATCH 027/124] =?UTF-8?q?revert(tradein/db):=20=D1=81=D0=BD?=
=?UTF-8?q?=D1=8F=D1=82=D1=8C=20250=20=D1=81=20=D0=B4=D0=B5=D0=BF=D0=BB?=
=?UTF-8?q?=D0=BE=D1=8F=20=E2=80=94=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4?=
=?UTF-8?q?=D1=8C=20=D0=B7=D0=B0=20=D0=BB=D0=BE=D0=BA=D0=BE=D0=BC=20=D0=B1?=
=?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D1=83=D0=B5=D1=82=20=D0=BF=D0=B0?=
=?UTF-8?q?=D0=B9=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD=20(#2792)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../250_drop_duplicate_expires_at_index.sql | 98 -------------------
1 file changed, 98 deletions(-)
delete 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
deleted file mode 100644
index b0866aaa..00000000
--- a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql
+++ /dev/null
@@ -1,98 +0,0 @@
--- 250_drop_duplicate_expires_at_index.sql
--- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at).
---
--- 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). То есть планировщик перевёл на новый ВЕСЬ живой трафик.
---
--- Причина не семантическая, а физическая: индексы идентичны, но новый
--- собран вчера с нуля и плотнее упакован — relpages 5 против 6 у старого,
--- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск
--- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost,
--- и при прочих равных выигрывает. Никакого нового запроса не появилось:
--- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого,
--- 0.93 у нового) — это один и тот же класс точечных 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):
--- 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` (129 строк из 1061), и
--- планировщик берёт именно этот, более селективный индекс, а expires_at
--- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не
--- участвует. Так что аргумента «оставить именно индекс из 229, он заведён
--- под конкретный запрос» не существует — запрос его не использует.
--- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму
--- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с
--- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД.
---
--- ── Планы ДО и ПОСЛЕ ─────────────────────────────────────────────────────────
--- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе:
--- меняется только имя индекса в строке плана и 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 идентичны; отличается только имя.
--- На проде разрыв плотности меньше (5 против 6 страниц, а не 5 против 8),
--- то есть и дельта cost будет меньше синтетической.
---
--- ── Стоимость блокировки ─────────────────────────────────────────────────────
--- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. Здесь это дёшево:
--- trade_in_estimates — 1061 строка, heap 1856 kB (231 страница), сам
--- сносимый индекс 40 kB. DROP INDEX ничего не переписывает — это удаление
--- строк каталога плюс unlink файла, единицы миллисекунд. 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. Ничего не ждёт и никого не блокирует.
---
--- Критерий приёмки (записан ДО применения):
--- после деплоя pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx
--- должен СДВИНУТЬСЯ с 234 в течение часа. Если он останется 234, значит
--- трафик ушёл не на переживший индекс, а в Seq Scan — это опровергло бы
--- разбор выше и требовало бы отката.
-
-BEGIN;
-
-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). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.';
-
-COMMIT;
From 482deb48646ec013fe6920d32a208f00b6196936 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Fri, 7 Aug 2026 11:21:28 +0000
Subject: [PATCH 028/124] =?UTF-8?q?fix(migrations):=20=D0=B7=D0=B0=D0=BA?=
=?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=B8=D1=82=D1=8C=20lock=5Ftimeout=20=D0=B4?=
=?UTF-8?q?=D0=BB=D1=8F=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D1=83=D1=8E?=
=?UTF-8?q?=D1=89=D0=B5=D0=B3=D0=BE=20DDL=20=D0=B8=20=D0=BB=D0=BE=D0=B2?=
=?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?=
=?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D1=8B?=
=?UTF-8?q?=20(#2791)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.claude/rules/sql.md | 32 ++++
.forgejo/workflows/ci.yml | 9 +
.forgejo/workflows/deploy-tradein.yml | 29 +++
.forgejo/workflows/deploy.yml | 25 +++
scripts/check-migration-lock-timeout.py | 242 ++++++++++++++++++++++++
5 files changed, 337 insertions(+)
create mode 100644 scripts/check-migration-lock-timeout.py
diff --git a/.claude/rules/sql.md b/.claude/rules/sql.md
index f221b4ef..136bc01a 100644
--- a/.claude/rules/sql.md
+++ b/.claude/rules/sql.md
@@ -16,11 +16,43 @@ paths:
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
BEGIN;
+SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout
+
-- DDL здесь (idempotent)
COMMIT;
```
+## lock_timeout при блокирующем DDL (обязательно)
+
+Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) /
+`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';`
+сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml`
+на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL).
+
+**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX`
+на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической
+psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за
+ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только
+ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный
+отказ, повторить позже) вместо тихой очереди перед приложением.
+
+**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена
+мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду,
+поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит
+очередь запросов приложения.
+
+**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует):
+`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через
+VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение,
+оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально
+в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и
+обрежет `CONCURRENTLY` ниже по файлу.
+
+Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в
+`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX
+CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий.
+
## Idempotency (обязательно)
- `CREATE TABLE IF NOT EXISTS`
diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index 0719e2a3..53e1d59b 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -65,6 +65,15 @@ jobs:
python3 scripts/check-workflow-ports.py --selftest
python3 scripts/check-workflow-ports.py
+ - name: "Guard: блокирующий DDL без lock_timeout (#2752)"
+ # Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
+ # включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
+ # фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ
+ # каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину.
+ run: |
+ python3 scripts/check-migration-lock-timeout.py --selftest
+ python3 scripts/check-migration-lock-timeout.py
+
- uses: dorny/paths-filter@v3
id: filter
with:
diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml
index b93af07c..ee062b18 100644
--- a/.forgejo/workflows/deploy-tradein.yml
+++ b/.forgejo/workflows/deploy-tradein.yml
@@ -452,6 +452,35 @@ jobs:
done
echo "All migrations applied."
+ # (3b) Невалидные индексы после цикла (#2752). Оборванный
+ # CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
+ # планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка
+ # на записи всё равно платится. Молчит это так (воспроизведено на
+ # PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена
+ # применённой → следующий деплой прогоняет её заново → `CREATE INDEX
+ # CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает
+ # «relation already exists, skipping», выходит с кодом 0 → миграция
+ # помечается применённой, а индекс остаётся невалидным навсегда.
+ # Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она
+ # ловит и этот путь, и невалидные индексы любого другого
+ # происхождения (отменённый job, ручной CIC оператором).
+ # На 2026-08-07 на проде таких индексов 0 — это профилактика.
+ invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
+ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
+ "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
+ | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
+ || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
+ if [ -n "$invalid_idx" ]; then
+ echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
+ echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
+ echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
+ echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
+ echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
+ echo " пересоздать индекс и повторить деплой."
+ exit 1
+ fi
+ echo "✓ невалидных индексов нет."
+
# Bootstrap gendesign_reader password from env (post-migration, #976).
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml
index 7303d4cf..f267444a 100644
--- a/.forgejo/workflows/deploy.yml
+++ b/.forgejo/workflows/deploy.yml
@@ -309,6 +309,31 @@ jobs:
done
echo "All migrations applied."
+ # Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX
+ # CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ
+ # пользуется, а re-run миграции его не чинит — `CREATE INDEX
+ # CONCURRENTLY IF NOT EXISTS` печатает «relation already exists,
+ # skipping» и выходит с кодом 0, после чего миграция помечается
+ # применённой, а индекс остаётся битым навсегда (воспроизведено на
+ # PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY.
+ # Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07
+ # на проде таких индексов 0 — это профилактика.
+ invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
+ psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
+ "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
+ | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
+ || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
+ if [ -n "$invalid_idx" ]; then
+ echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
+ echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
+ echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
+ echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
+ echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
+ echo " пересоздать индекс и повторить деплой."
+ exit 1
+ fi
+ echo "✓ невалидных индексов нет."
+
# Set tradein_fdw_reader password from env (post-migration bootstrap).
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
# password lives only in /opt/gendesign/backend/.env.runtime.
diff --git a/scripts/check-migration-lock-timeout.py b/scripts/check-migration-lock-timeout.py
new file mode 100644
index 00000000..92b1e3ff
--- /dev/null
+++ b/scripts/check-migration-lock-timeout.py
@@ -0,0 +1,242 @@
+#!/usr/bin/env python3
+"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752).
+
+ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала
+на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за
+чужой аналитической psql-сессией; вторая попытка деплоя — ещё 16 минут. Опасность
+не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
+запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за
+ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих
+data/sql — то есть следующий блокирующий DDL повторил бы это.
+
+`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный
+CREATE INDEX он не оборвёт, а очередь — не соберёт. Срабатывание = красный деплой
+(ON_ERROR_STOP=on) вместо тихой очереди перед приложением.
+
+ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант
+«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4:
+`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW
+lock_timeout` → 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX
+CONCURRENTLY` — тот ждёт завершения параллельных транзакций через VirtualXactLock,
+и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная
+сессия просто держала открытую транзакцию (ACCESS SHARE — с CIC вообще не
+конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение
+изготавливало бы ровно ту аварию, от которой заведена проверка невалидных
+индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят
+противоположной политики, поэтому granularity — файл, а не раннер.
+
+ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца
+сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL`
+молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента.
+
+ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы
+задним числом не переписываются. Гейт смотрит только вперёд.
+
+Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest]
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+# каталог миграций -> минимальный NN, с которого правило обязательно.
+# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая).
+SQL_DIRS: dict[str, int] = {
+ "data/sql": 189,
+ "tradein-mvp/backend/data/sql": 250,
+}
+
+# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE,
+# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать
+# в очередь и увести за собой запросы приложения.
+BLOCKING = re.compile(
+ r"\b(?:"
+ r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|"
+ r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|"
+ r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|"
+ r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL"
+ r")\b",
+ re.IGNORECASE,
+)
+# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку).
+# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE
+# по-прежнему обязан прикрыть свой ALTER.
+CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE)
+
+BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE)
+SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE)
+SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE)
+NN_PREFIX = re.compile(r"^(\d+)")
+
+
+def strip_noise(sql: str) -> str:
+ """Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на
+ пробелы (сохраняя длину и переводы строк — номера строк не съезжают).
+
+ Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...'
+ легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу.
+ Тела $$...$$ (DO-блоки) НЕ затираются — там живёт исполняемый DDL.
+ """
+ out: list[str] = []
+ i, n = 0, len(sql)
+ while i < n:
+ ch = sql[i]
+ nxt = sql[i + 1] if i + 1 < n else ""
+ if ch == "-" and nxt == "-":
+ while i < n and sql[i] != "\n":
+ out.append(" ")
+ i += 1
+ elif ch == "/" and nxt == "*":
+ depth = 1 # в PostgreSQL блочные комментарии вложенные
+ out.append(" ")
+ i += 2
+ while i < n and depth:
+ if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*":
+ depth += 1
+ out.append(" ")
+ i += 2
+ elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/":
+ depth -= 1
+ out.append(" ")
+ i += 2
+ else:
+ out.append("\n" if sql[i] == "\n" else " ")
+ i += 1
+ elif ch == "'":
+ out.append("'")
+ i += 1
+ while i < n:
+ if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
+ out.append(" ")
+ i += 2
+ continue
+ if sql[i] == "'":
+ break
+ out.append("\n" if sql[i] == "\n" else " ")
+ i += 1
+ if i < n:
+ out.append("'")
+ i += 1
+ else:
+ out.append(ch)
+ i += 1
+ return "".join(out)
+
+
+def scan(sql: str) -> list[str]:
+ """-> список претензий к файлу; пустой список = файл в порядке."""
+ clean = strip_noise(sql)
+ statements = clean.split(";")
+
+ first_blocking: int | None = None
+ blocking_text = ""
+ for idx, stmt in enumerate(statements):
+ if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt):
+ first_blocking = idx
+ blocking_text = " ".join(stmt.split())[:80]
+ break
+ if first_blocking is None:
+ return []
+
+ set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None)
+ if set_local is None:
+ if any(SET_BARE_LT.search(s) for s in statements):
+ return [
+ f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). "
+ "Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY "
+ "в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN."
+ ]
+ return [
+ f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой "
+ "строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет "
+ "в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)."
+ ]
+
+ problems: list[str] = []
+ if not any(BEGIN_STMT.search(s) for s in statements[:set_local]):
+ problems.append(
+ "`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). "
+ "Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)."
+ )
+ if set_local > first_blocking:
+ problems.append(
+ f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — "
+ "к моменту DDL он ещё не действует. Подними его сразу под BEGIN."
+ )
+ return problems
+
+
+def selftest() -> None:
+ ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
+ assert scan(ok) == [], scan(ok)
+
+ # красное: ровно случай 250 до фикса
+ bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
+ assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0]
+ assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n")
+ assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n")
+ assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n")
+ assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n")
+
+ # красное: правильная строка, но в местах, где она не действует
+ assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0]
+ late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n"
+ assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late))
+ bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
+ assert "без LOCAL" in scan(bare)[0]
+
+ # зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC)
+ assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == []
+ assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == []
+ assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == []
+ # ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL
+ mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
+ assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле"
+ mixed_ok = (
+ "CREATE INDEX CONCURRENTLY i ON t (c);\n"
+ "BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
+ )
+ assert scan(mixed_ok) == [], scan(mixed_ok)
+
+ # зелёное: DDL, которого нет — он в комментарии или в строковом литерале
+ assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == []
+ assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == []
+ assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == []
+ assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == []
+ assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == []
+ # зелёное: не-DDL миграции (backfill/seed) правила не касаются
+ assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == []
+ assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == []
+ print("selftest OK")
+
+
+def main() -> int:
+ if "--selftest" in sys.argv:
+ selftest()
+ return 0
+
+ failed = False
+ checked = 0
+ for dirname, min_nn in SQL_DIRS.items():
+ sql_dir = Path(dirname)
+ if not sql_dir.is_dir():
+ print(f"::error::{sql_dir} не найден — запускать из корня репозитория")
+ return 1
+ for path in sorted(sql_dir.glob("*.sql")):
+ m = NN_PREFIX.match(path.name)
+ if not m or int(m.group(1)) < min_nn:
+ continue # применено на проде до внедрения гейта — не переписываем
+ checked += 1
+ for problem in scan(path.read_text(encoding="utf-8")):
+ failed = True
+ print(f"::error file={path}::{problem}")
+ if failed:
+ return 1
+ print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From e6591a450a829f94924bf85a3fa6c541ff10a9c8 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Fri, 7 Aug 2026 15:31:01 +0300
Subject: [PATCH 029/124] =?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 030/124] =?UTF-8?q?fix(tradein/payments):=20SET=20LOCAL=20?=
=?UTF-8?q?lock=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 031/124] =?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 032/124] =?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 033/124] =?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 034/124] =?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 037/124] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D0=B7?=
=?UTF-8?q?=D0=B5=D0=BB,=20=D0=B7=D0=B0=D1=80=D0=B5=D0=B7=D0=B5=D1=80?=
=?UTF-8?q?=D0=B2=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20?=
=?UTF-8?q?=D0=B7=D0=B0=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE?=
=?UTF-8?q?=D0=BC,=20=D0=B4=D0=BE=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8?=
=?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4?=
=?UTF-8?q?=D0=B8=D1=82=20(#2800=20A)=20(#2802)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../backend/app/services/proxy_pool.py | 23 +++++---
...scrape_proxy_domclick_affinity_release.sql | 52 +++++++++++++++++++
2 files changed, 67 insertions(+), 8 deletions(-)
create mode 100644 tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql
diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py
index ed40dc63..5e2cb9c5 100644
--- a/tradein-mvp/backend/app/services/proxy_pool.py
+++ b/tradein-mvp/backend/app/services/proxy_pool.py
@@ -228,14 +228,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
чужой affinity (#2600).
- Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см.
- 173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1),
- намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого
- одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex,
- domclick останется без прокси вообще — хуже, чем голодание исходного источника,
- которое фикс призван устранить. Кандидат участвует в fallback, только если его
- affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) —
- т.е. выдача не обнулит доступность выделенной affinity целиком.
+ Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
+ fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже,
+ чем голодание исходного источника, которое фикс призван устранить. Кандидат
+ участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
+ enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной
+ affinity целиком.
+
+ Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql —
+ единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
+ банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до
+ рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а
+ Авито/Яндекс через него работают — резервация держала узел за источником, которому
+ он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде
+ сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и
+ следующий выделенный узел должен получить её сразу, а не после повторного разбора.
ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у
(scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито,
diff --git a/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql
new file mode 100644
index 00000000..b1f63939
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql
@@ -0,0 +1,52 @@
+-- 253_scrape_proxy_domclick_affinity_release.sql
+-- Снять с узла резервацию provider_affinity='domclick' (#2800).
+--
+-- WHY (замер, не рассуждение — живая проба 09.08.2026, тракт сайдкар+camoufox,
+-- POST /fetch на robots.txt рабочего хоста каждой площадки):
+--
+-- узел | affinity | avito | ekb.cian.ru | realty.yandex.ru | bff-search-web.domclick.ru
+-- -----+----------+-------+--------------------+------------------+---------------------------
+-- 1 | domclick | 200 | 200 «Ошибка — Циан»| 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY
+-- 9 | any | 200 | 200 | 200 | 200
+-- 10 | any | 200 | 200 | 200 | 200
+-- 11 | any | 200 | 200 | 200 | 200
+--
+-- Узел, закреплённый 173-й миграцией СПЕЦИАЛЬНО за Домкликом, до рабочего хоста
+-- Домклика не доходит вообще (NS_ERROR_PROXY_BAD_GATEWAY на bff-search-web —
+-- именно туда ходит боевой сбор, см. providers/domclick/serp.py::_BFF_BASE), при
+-- этом Авито и Яндекс через него отвечают штатно. Резервация даёт ровно обратный
+-- эффект задуманному: единственный источник, которому узел ГОДЕН НЕ БЫЛ, держал его
+-- за собой, а два источника, которым он годен, его не видели —
+-- acquire('avito'|'yandex') отбирает по provider_affinity IN (source,'any'), а
+-- fallback этот узел не берёт (защита последнего узла выделенной affinity).
+--
+-- 'any', а НЕ enabled=false: узел жив для двух площадок из четырёх, выключать его
+-- целиком — терять четверть и без того дефицитного пула (#2638).
+--
+-- WHAT:
+-- provider_affinity='domclick' → 'any' для узлов, у которых affinity именно такая.
+-- CHECK-констрейнт (173) не трогаем: значение 'domclick' остаётся допустимым, если
+-- в пуле появится узел, который до Домклика реально доходит.
+--
+-- ЧТО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ (граница честная):
+-- Она НЕ чинит Домклик. acquire('domclick') и до неё видел все четыре узла
+-- (affinity IN ('domclick','any')), т.е. шанс вытянуть узел 1 и потратить первый
+-- бакет впустую был и остаётся 1/4 — закрывает это проба по паре «узел × источник»
+-- (#2800 часть B), а не смена affinity. Здесь снимается только резервация.
+--
+-- IDEMPOTENCY / SAFETY:
+-- Один UPDATE в транзакции; повторный прогон не находит строк (no-op) — auto-apply
+-- strict на деплое это требует. Блокирующего DDL нет (см.
+-- scripts/check-migration-lock-timeout.py: правило про ALTER/DROP/CREATE INDEX),
+-- UPDATE берёт row-lock на единичные строки.
+--
+-- Dependencies: 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql
+
+BEGIN;
+
+UPDATE scrape_proxies
+SET provider_affinity = 'any',
+ updated_at = now()
+WHERE provider_affinity = 'domclick';
+
+COMMIT;
From 08bb9d6549b106aac5b8b4927ba512b01f86f8ec Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Sun, 9 Aug 2026 18:28:37 +0000
Subject: [PATCH 038/124] =?UTF-8?q?fix(tradein/proxy):=20=D0=BF=D1=80?=
=?UTF-8?q?=D0=BE=D0=B1=D0=B0=20=D1=81=D0=BF=D1=80=D0=B0=D1=88=D0=B8=D0=B2?=
=?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=83=D1=8E=20?=
=?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D1=83,=20=D0=B2?=
=?UTF-8?q?=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82=20=D0=BF=D0=B8=D1=88=D0=B5?=
=?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=BF=D0=B0=D1=80=D1=83=20?=
=?UTF-8?q?=C2=AB=D1=83=D0=B7=D0=B5=D0=BB=20=C3=97=20=D0=B8=D1=81=D1=82?=
=?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=C2=BB=20(#2800=20B)=20(#2803)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../backend/app/services/proxy_pool.py | 281 +++++++++++--
.../backend/tests/services/test_proxy_pool.py | 8 +-
.../backend/tests/test_2723_browser_probe.py | 18 +-
.../tests/test_2800_per_source_probe.py | 382 ++++++++++++++++++
.../src/scraper_kit/browser_fetcher.py | 79 +++-
5 files changed, 717 insertions(+), 51 deletions(-)
create mode 100644 tradein-mvp/backend/tests/test_2800_per_source_probe.py
diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py
index 5e2cb9c5..bf1f26e7 100644
--- a/tradein-mvp/backend/app/services/proxy_pool.py
+++ b/tradein-mvp/backend/app/services/proxy_pool.py
@@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
«Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
+Проба на ПАРУ «узел × источник» (#2800, продолжение #2723):
+ - #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом
+ для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв»
+ вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500
+ NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и
+ свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен».
+ - Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может
+ достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки
+ (apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а
+ `bff-search-web.domclick.ru`, куда ходит сбор, — 500).
+ - Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой
+ сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый
+ отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки.
+ Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt
+ площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать
+ дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»).
+ - Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение
+ «браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО
+ креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла.
+ Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками
+ вернули то самое схлопывание диагнозов.
+
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
@@ -125,6 +147,7 @@ __all__ = [
"mark_banned",
"mark_browser_health",
"mark_health",
+ "mark_source_probe",
"reap_stale_leases",
"release",
"run_proxy_healthcheck",
@@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360
# намеренно не обновляется (см. mark_browser_health).
BROWSER_UNFIT_THRESHOLD = 2
+# ── проба на пару «узел × источник» (#2800) ──────────────────────────────────
+# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт):
+# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито;
+# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть
+# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch;
+# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов
+# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at).
+# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при
+# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было.
+# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и
+# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором
+# acquire снова выдаёт узел, не спросив.
+#
+# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её
+# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная
+# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка
+# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка
+# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре.
+_PROBE_BAN_REASON = "probe:browser"
+
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
@@ -682,9 +725,14 @@ def mark_browser_health(
return "fail"
-def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
+def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> None:
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
+ `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
+ умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы —
+ _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
+ clear_source_bans умеет фильтровать по ней (`only_reason`).
+
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
@@ -820,7 +868,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
{
"proxy_id": proxy_id,
"source": source,
- "reason": f"banned:{source}",
+ "reason": reason or f"banned:{source}",
"base_hours": SOURCE_BAN_BASE_HOURS,
"max_hours": SOURCE_BAN_MAX_HOURS,
"max_fails": MAX_CONSECUTIVE_FAILS,
@@ -866,7 +914,14 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
)
-def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
+def clear_source_bans(
+ db: Session,
+ proxy_id: int,
+ *,
+ source: str | None = None,
+ reason: str,
+ only_reason: str | None = None,
+) -> int:
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
@@ -889,6 +944,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
SOURCE_BAN_BASE_HOURS.
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
+
+ `only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
+ написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
+ свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан,
+ распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация
+ IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной
+ целиком. None — снимать всё, как и раньше.
"""
rows = db.execute(
text(
@@ -896,10 +958,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
DELETE FROM scrape_proxy_source_bans
WHERE proxy_id = CAST(:proxy_id AS bigint)
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
+ AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
RETURNING source
"""
),
- {"proxy_id": proxy_id, "source": source},
+ {"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
).fetchall()
db.commit()
if rows:
@@ -913,6 +976,75 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
return len(rows)
+def mark_source_probe(
+ db: Session,
+ proxy_id: int,
+ *,
+ source: str,
+ ok: bool,
+ fail_kind: str | None = None,
+ detail: str = "",
+) -> str:
+ """Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800).
+
+ Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или
+ не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает
+ `acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим.
+
+ КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница
+ другая — здесь судится ПАРА, а не узел):
+ - "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored".
+ Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз);
+ - "proxy" — через этот узел до площадки не доходит транспорт
+ (NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары;
+ - "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку
+ (200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары.
+ Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для
+ ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя.
+
+ Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный
+ боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить
+ дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре.
+
+ Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего
+ своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не
+ пишется (голодание хуже работы через плохой узел).
+
+ Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored".
+ """
+ if ok:
+ cleared = clear_source_bans(
+ db,
+ proxy_id,
+ source=source,
+ reason=f"browser probe OK for source={source} ({detail})",
+ only_reason=_PROBE_BAN_REASON,
+ )
+ return "cleared" if cleared else "ok"
+
+ if fail_kind not in ("proxy", "page"):
+ logger.warning(
+ "proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре "
+ "(fail_kind=%s): %s — вердикт не пишем",
+ proxy_id,
+ source,
+ fail_kind,
+ detail,
+ )
+ return "ignored"
+
+ logger.warning(
+ "proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан "
+ "пары, узел остаётся первосортным для остальных площадок (#2800)",
+ proxy_id,
+ source,
+ fail_kind,
+ detail,
+ )
+ mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
+ return "banned"
+
+
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
@@ -976,8 +1108,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
return False, None, None, "other"
-async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str:
- """Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health.
+def _probe_sources_for(affinity: str) -> list[str]:
+ """Источники, которым узел с такой affinity МОЖЕТ достаться (#2800).
+
+ Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`.
+ Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику,
+ которой никто не воспользуется.
+
+ ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих
+ свободных нет) — такая пара останется без вердикта и решится как раньше, по факту
+ прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же
+ только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос
+ бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4.
+ """
+ from scraper_kit.browser_fetcher import PROBE_SOURCES
+
+ return [s for s in PROBE_SOURCES if affinity in (s, "any")]
+
+
+async def _run_pair_probes(
+ db: Session, proxy_id: int, url: str, kind: str, affinity: str
+) -> tuple[str, dict[str, int]]:
+ """Крест «этот узел × каждая его площадка» + запись вердиктов (#2800).
+
+ Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам).
+
+ Два уровня вердикта, и они не пересекаются:
+ - ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке
+ отдельно, это то, что читает `acquire(source)`;
+ - УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по
+ итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел
+ работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки
+ узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов
+ появилось бы новое.
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не
@@ -985,18 +1148,60 @@ async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) ->
"""
from scraper_kit.browser_fetcher import probe_proxy_via_browser
- try:
- ok, fail_kind, detail = await probe_proxy_via_browser(
- _settings.browser_http_endpoint, url, proxy_kind=kind
+ counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
+ fail_kinds: list[str] = []
+ any_ok = False
+ last_detail = ""
+
+ for source in _probe_sources_for(affinity):
+ try:
+ ok, fail_kind, detail = await probe_proxy_via_browser(
+ _settings.browser_http_endpoint, url, proxy_kind=kind, source=source
+ )
+ if not ok and fail_kind == "proxy":
+ # Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает
+ # флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор
+ # идёт по уже поднятому браузеру с тем же прокси — секунды, и только
+ # на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью
+ # (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания.
+ ok, fail_kind, detail = await probe_proxy_via_browser(
+ _settings.browser_http_endpoint, url, proxy_kind=kind, source=source
+ )
+ except Exception:
+ logger.warning(
+ "proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан",
+ proxy_id,
+ source,
+ exc_info=True,
+ )
+ continue
+
+ counters["pair_checked"] += 1
+ last_detail = detail
+ if ok:
+ any_ok = True
+ else:
+ fail_kinds.append(fail_kind or "other")
+ outcome = mark_source_probe(
+ db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail
)
- except Exception:
- logger.warning(
- "proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан",
- proxy_id,
- exc_info=True,
- )
- return "ignored"
- return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail)
+ if outcome == "banned":
+ counters["pair_banned"] += 1
+ elif outcome == "cleared":
+ counters["pair_cleared"] += 1
+
+ if counters["pair_checked"] == 0:
+ return "ignored", counters # крест не состоялся — узел не судим
+
+ if any_ok:
+ return mark_browser_health(db, proxy_id, True, detail=last_detail), counters
+ # Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ —
+ # транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле».
+ node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0]
+ return (
+ mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail),
+ counters,
+ )
def _mask(url: str) -> str:
@@ -1032,18 +1237,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
- БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером
- дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же
- тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на
- robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и
- никогда не смешивается с consecutive_fails/enabled. Гейт — settings.
- use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба
- измеряла бы то, чем никто не пользуется.
+ БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не
+ проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ
+ САЙДКАР — по одной навигации на каждую площадку, которую этот узел может
+ обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси,
+ потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в
+ scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные
+ browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт —
+ settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и
+ проба измеряла бы то, чем никто не пользуется.
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
- browser_unfit, browser_refit}.
+ browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}.
"""
reaped = reap_stale_leases(db)
@@ -1051,7 +1258,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
db.execute(
text(
"""
- SELECT id, url, kind, enabled, disabled_reason,
+ SELECT id, url, kind, enabled, disabled_reason, provider_affinity,
(browser_check_at IS NULL
OR browser_check_at < now() - make_interval(
mins => CAST(:browser_probe_minutes AS integer)
@@ -1082,6 +1289,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
browser_ok = 0
browser_unfit = 0
browser_refit = 0
+ pair_checked = 0
+ pair_banned = 0
+ pair_cleared = 0
for row in proxies:
proxy_id = int(row["id"])
url = str(row["url"])
@@ -1111,8 +1321,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
# не тот узел», из-за которого #2723 и появилась.
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
- outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"]))
+ outcome, pair_counters = await _run_pair_probes(
+ db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"])
+ )
browser_checked += 1
+ pair_checked += pair_counters["pair_checked"]
+ pair_banned += pair_counters["pair_banned"]
+ pair_cleared += pair_counters["pair_cleared"]
if outcome in ("ok", "refit"):
browser_ok += 1
if outcome == "refit":
@@ -1142,7 +1357,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
logger.info(
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
- "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d",
+ "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d "
+ "pair_checked=%d pair_banned=%d pair_cleared=%d",
reaped,
checked,
ok_count,
@@ -1153,6 +1369,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
browser_ok,
browser_unfit,
browser_refit,
+ pair_checked,
+ pair_banned,
+ pair_cleared,
)
return {
"reaped": reaped,
@@ -1167,4 +1386,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
"browser_ok": browser_ok,
"browser_unfit": browser_unfit,
"browser_refit": browser_refit,
+ # Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и
+ # pair_banned=2 одновременно — это не противоречие, а точный диагноз
+ # «браузер через узел работает, но две площадки его не пускают».
+ "pair_checked": pair_checked,
+ "pair_banned": pair_banned,
+ "pair_cleared": pair_cleared,
}
diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py
index a62f9886..bd360aed 100644
--- a/tradein-mvp/backend/tests/services/test_proxy_pool.py
+++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py
@@ -397,12 +397,18 @@ class FakeSession:
)
if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql:
- # clear_source_bans: снять баны узла (все либо один source), #2600 п.2
+ # clear_source_bans: снять баны узла (все либо один source), #2600 п.2.
+ # Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры
+ # в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и
+ # тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном.
+ filters_reason = "reason = CAST(:only_reason AS text)" in sql
+ only_reason = p.get("only_reason") if filters_reason else None
cleared = [
b
for b in self.bans
if b["proxy_id"] == p["proxy_id"]
and (p["source"] is None or b["source"] == p["source"])
+ and (only_reason is None or b.get("reason") == only_reason)
]
self.bans = [b for b in self.bans if b not in cleared]
return _FakeResult([{"source": b["source"]} for b in cleared])
diff --git a/tradein-mvp/backend/tests/test_2723_browser_probe.py b/tradein-mvp/backend/tests/test_2723_browser_probe.py
index 0bfb8dae..f44e0181 100644
--- a/tradein-mvp/backend/tests/test_2723_browser_probe.py
+++ b/tradein-mvp/backend/tests/test_2723_browser_probe.py
@@ -260,27 +260,35 @@ async def test_healthcheck_marks_unfit_when_http_green_browser_red(
row = db._by_id(1)
assert row["browser_unfit_since"] is not None
assert row["enabled"] is True and row["consecutive_fails"] == 0
- assert len(calls) == 2
+ # За такт узел опрашивается по КАЖДОЙ обслуживаемой площадке (#2800), а каждый
+ # транспортный отказ ещё и подтверждается повтором: 4 источника × 2 попытки за
+ # прогон. Узловой вердикт по-прежнему один на прогон — browser_checked == 1 выше.
+ assert len(calls) == 2 * 4 * 2
async def test_healthcheck_browser_probe_respects_slow_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет."""
+ """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.
+
+ Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на
+ число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой
+ по-прежнему один на BROWSER_PROBE_MINUTES.
+ """
calls: list[str] = []
_patch_probes(monkeypatch, calls=calls)
db = FakeSession([_proxy(1)])
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
- assert len(calls) == 1
+ assert len(calls) == 4
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
- assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox"
+ assert len(calls) == 4, "браузерная проба обязана идти реже ipify — она стоит camoufox"
db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta(
minutes=BROWSER_PROBE_MINUTES + 1
)
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
- assert len(calls) == 2
+ assert len(calls) == 8
async def test_healthcheck_skips_browser_probe_when_http_dead(
diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py
new file mode 100644
index 00000000..7eca4afd
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py
@@ -0,0 +1,382 @@
+"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ.
+
+Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь —
+верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный
+ответ означал «узел годен для Авито», а читался как «узел годен вообще».
+
+Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация):
+
+ узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru
+ -----+----------+-------+---------------------+--------------+---------------------------
+ 1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY
+ 10 | any | 200 | 200 | 200 | 200
+
+Что сторожится (каждый тест ниже падает на коде до фикса):
+
+ 1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для
+ Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')`
+ выдавал его как ни в чём не бывало.
+ 2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен
+ (`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной).
+ 3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех.
+ 4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и
+ забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что
+ «дешёвая ipify стирает браузерный вердикт» в #2723).
+ 5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой
+ вердикт #2723 сохранён).
+ 6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс).
+ 7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников.
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+import pytest
+import scraper_kit.browser_fetcher as bf
+
+from app.services import proxy_pool
+from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release
+from tests.services.test_proxy_pool import FakeSession, _proxy
+
+# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным.
+_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = {
+ ("http://u:p@h1:8080", "domclick"): (
+ False,
+ "proxy",
+ '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}',
+ ),
+}
+
+
+def _patch_probes(
+ monkeypatch: pytest.MonkeyPatch,
+ matrix: dict[tuple[str, str], tuple[bool, str | None, str]],
+ calls: list[tuple[str, str]] | None = None,
+ *,
+ default: tuple[bool, str | None, str] = (True, None, "html_len=16477"),
+) -> None:
+ """ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник)."""
+
+ async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
+ return True, "1.2.3.4", 10, None
+
+ async def _fake_browser(
+ endpoint: str, proxy_url: str, **kw: Any
+ ) -> tuple[bool, str | None, str]:
+ source = str(kw.get("source", "avito"))
+ if calls is not None:
+ calls.append((proxy_url, source))
+ return matrix.get((proxy_url, source), default)
+
+ monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
+ monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
+ monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser)
+
+
+# ── 1. главное: вердикт разведён по источникам ───────────────────────────────
+
+
+async def test_node_dead_for_domclick_is_not_issued_to_domclick(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём.
+
+ Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при
+ `browser_fail_streak=0` и свежей пробе.
+ """
+ _patch_probes(monkeypatch, _LIVE_MATRIX)
+ db = FakeSession([_proxy(1), _proxy(2)])
+
+ counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
+
+ # Домклику достаётся только исправный узел…
+ first = acquire(db, "domclick") # type: ignore[arg-type]
+ assert first is not None and first.id == 2
+
+ # …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить
+ # заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой
+ # заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0.
+ assert acquire(db, "domclick") is None, ( # type: ignore[arg-type]
+ "Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY"
+ )
+
+ # Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла.
+ lease = acquire(db, "avito") # type: ignore[arg-type]
+ assert lease is not None and lease.id == 1
+
+ ban = db._ban(1, "domclick")
+ assert ban is not None
+ assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба"
+ assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None
+ assert counters["pair_banned"] == 1
+
+
+async def test_one_dead_platform_does_not_stain_the_node_globally(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает."""
+ _patch_probes(monkeypatch, _LIVE_MATRIX)
+ db = FakeSession([_proxy(1), _proxy(2)])
+
+ await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
+
+ row = db._by_id(1)
+ assert row["browser_unfit_since"] is None
+ assert row["browser_fail_streak"] == 0
+ assert row["enabled"] is True and row["consecutive_fails"] == 0
+
+
+async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел)."""
+ dead_everywhere = {
+ ("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable")
+ for src in ("avito", "cian", "yandex", "domclick")
+ }
+ _patch_probes(monkeypatch, dead_everywhere)
+ db = FakeSession([_proxy(1), _proxy(2)])
+
+ for _ in range(BROWSER_UNFIT_THRESHOLD):
+ db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение
+ await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
+
+ assert db._by_id(1)["browser_unfit_since"] is not None
+ assert db._by_id(2)["browser_unfit_since"] is None
+
+
+# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ────────────────────────────────
+
+
+@pytest.mark.parametrize(
+ ("source", "must_contain"),
+ [
+ ("avito", "www.avito.ru"),
+ ("cian", "ekb.cian.ru"),
+ ("yandex", "realty.yandex.ru"),
+ # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
+ # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
+ ("domclick", "bff-search-web.domclick.ru"),
+ ],
+)
+async def test_probe_asks_the_working_host_of_each_source(
+ monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
+) -> None:
+ seen: dict[str, Any] = {}
+
+ class _Resp:
+ status_code = 200
+ text = '{"html": "
"}
+
+ 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 039/124] =?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 040/124] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D0=BF?=
=?UTF-8?q?=D0=B0=D0=B2=D1=88=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B0?=
=?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D1=81=D0=B2=D0=B0=D0=B8=D0=B2=D0=B0=D0=BB?=
=?UTF-8?q?=D0=B0=20=D1=81=D0=B5=D0=B1=D0=B5=20=D0=B1=D0=B0=D0=BD=20=D0=B1?=
=?UTF-8?q?=D0=BE=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE=D1=80?=
=?UTF-8?q?=D0=B0=20(#2800)=20(#2805)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../backend/app/services/proxy_pool.py | 109 +++++++++++++++---
.../backend/tests/services/test_proxy_pool.py | 17 +++
.../tests/test_2800_per_source_probe.py | 76 +++++++++++-
3 files changed, 184 insertions(+), 18 deletions(-)
diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py
index bf1f26e7..8ed2176e 100644
--- a/tradein-mvp/backend/app/services/proxy_pool.py
+++ b/tradein-mvp/backend/app/services/proxy_pool.py
@@ -725,14 +725,43 @@ def mark_browser_health(
return "fail"
-def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> None:
+def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str:
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
+ Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары
+ принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего
+ узла) | "missing" (нет такого proxy_id).
+
`reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы —
_PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
clear_source_bans умеет фильтровать по ней (`only_reason`).
+ ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026:
+ пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через
+ ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр
+ «снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ:
+ присвоенная строка становится «своей», и следующая успешная проба снимает ею бан,
+ который поставил боевой сбор по настоящему отказу площадки. Плюс теряется
+ происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба
+ не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count
+ начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых
+ пары с 6 ч до 12 ч).
+
+ Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет),
+ ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`).
+ Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно»
+ только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен
+ УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего.
+
+ АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее
+ (площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится
+ ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный
+ robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально
+ и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного);
+ обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об
+ эскалации боевых банов пары.
+
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
@@ -791,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает
(падает как обычно) — caller решает, ловить или нет.
"""
+ # Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё
+ # (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ").
+ live_reason = f"banned:{source}"
+ effective_reason = reason or live_reason
# Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг
# "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции.
db.execute(
@@ -862,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
) AS integer)),
reason = CAST(:reason AS text),
updated_at = now()
+ -- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
+ -- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее
+ -- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
+ WHERE scrape_proxy_source_bans.banned_until <= now()
+ OR scrape_proxy_source_bans.reason = CAST(:reason AS text)
+ OR CAST(:reason AS text) = CAST(:live_reason AS text)
RETURNING ban_count, banned_until
"""
),
{
"proxy_id": proxy_id,
"source": source,
- "reason": reason or f"banned:{source}",
+ "reason": effective_reason,
+ "live_reason": live_reason,
"base_hours": SOURCE_BAN_BASE_HOURS,
"max_hours": SOURCE_BAN_MAX_HOURS,
"max_fails": MAX_CONSECUTIVE_FAILS,
@@ -888,10 +928,40 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
row["banned_until"],
row["ban_count"],
)
- return
+ return "banned"
+
+ # 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита
+ # последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога
+ # (на решение уже не влияет), но диагноз должен называть то, что произошло.
+ holder = (
+ db.execute(
+ text(
+ """
+ SELECT reason, banned_until
+ FROM scrape_proxy_source_bans
+ WHERE proxy_id = CAST(:proxy_id AS bigint)
+ AND source = CAST(:source AS text)
+ AND banned_until > now()
+ """
+ ),
+ {"proxy_id": proxy_id, "source": source},
+ )
+ .mappings()
+ .fetchone()
+ )
+ if holder is not None and holder["reason"] != effective_reason:
+ logger.info(
+ "proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт "
+ "%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, "
+ "иначе проба присвоила бы чужой бан и потом сняла бы его как свой)",
+ proxy_id,
+ source,
+ holder["reason"],
+ holder["banned_until"],
+ effective_reason,
+ )
+ return "deferred"
- # 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем
- # текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет).
current = (
db.execute(
text(
@@ -904,14 +974,15 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
)
if current is None:
logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id)
- else:
- logger.warning(
- "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
- "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
- "источнику (голодание хуже, чем работа через забаненный узел).",
- proxy_id,
- source,
- )
+ return "missing"
+ logger.warning(
+ "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
+ "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
+ "источнику (голодание хуже, чем работа через забаненный узел).",
+ proxy_id,
+ source,
+ )
+ return "protected"
def clear_source_bans(
@@ -1004,13 +1075,18 @@ def mark_source_probe(
Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный
боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить
- дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре.
+ дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная
+ половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не
+ присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь
+ присвоенная строка уже «своя».
Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего
своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не
пишется (голодание хуже работы через плохой узел).
- Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored".
+ Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned`
+ ("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном
+ только реально записанный бан.
"""
if ok:
cleared = clear_source_bans(
@@ -1041,8 +1117,7 @@ def mark_source_probe(
fail_kind,
detail,
)
- mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
- return "banned"
+ return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py
index bd360aed..478e394a 100644
--- a/tradein-mvp/backend/tests/services/test_proxy_pool.py
+++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py
@@ -387,6 +387,17 @@ class FakeSession:
}
self.bans.append(ban)
else:
+ # #2803-follow-up: активную строку чужого владельца не перехватываем.
+ # Гейтим по подстроке боевого SQL (как ban-предикаты выше) — иначе мок
+ # реализовал бы защиту сам и тест был бы зелёным на сломанном коде.
+ defends_owner = "scrape_proxy_source_bans.banned_until <= now()" in sql
+ if (
+ defends_owner
+ and ban["banned_until"] > now
+ and ban.get("reason") != p["reason"]
+ and p["reason"] != p.get("live_reason")
+ ):
+ return _FakeResult([]) # владельца активной строки не меняем
# эскалация: срок = base * 2^(новый ban_count - 1), потолок max_hours
ban["ban_count"] += 1
hours = min(p["base_hours"] * 2 ** (ban["ban_count"] - 1), p["max_hours"])
@@ -419,6 +430,12 @@ class FakeSession:
self.bans = [b for b in self.bans if b["banned_until"] >= cutoff]
return _FakeResult(purged)
+ if "SELECT reason, banned_until" in sql: # mark_banned: кто держит активный бан пары
+ ban = self._ban(p["proxy_id"], p["source"])
+ if ban is None or ban["banned_until"] <= datetime.now(UTC):
+ return _FakeResult([])
+ return _FakeResult([{"reason": ban.get("reason"), "banned_until": ban["banned_until"]}])
+
if "SELECT enabled, disabled_reason FROM scrape_proxies" in sql: # mark_banned diag read
row = self._by_id(p["id"])
if row is None:
diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py
index 7eca4afd..37fc10fe 100644
--- a/tradein-mvp/backend/tests/test_2800_per_source_probe.py
+++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py
@@ -283,11 +283,85 @@ async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) ->
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
- assert db._ban(1, "avito") is not None, "чужой бан проба снимать не имеет права"
+ avito = db._ban(1, "avito")
+ assert avito is not None, "чужой бан проба снимать не имеет права"
+ assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать"
assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять"
assert counters["pair_cleared"] == 1
+# ── 4b. …и не присваивает чужую (дефект #2803, реализовался на проде) ─────────
+
+
+async def test_probe_does_not_steal_a_live_ban(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Упавшая проба НЕ переписывает активный бан, поставленный боевым сбором.
+
+ Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21` после
+ упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. Фильтр «снимаю только
+ своё» при этом цел, но защищать перестаёт: присвоенная строка уже «своя», и
+ следующая успешная проба сняла бы ею бан, который площадка поставила по-настоящему.
+ Плюс сама метка перестаёт быть свидетельством («нас отбили» неотличимо от «мы не
+ смогли», #2764), а ban_count складывает события разного рода в одну эскалацию —
+ отдых пары вырос с 6 ч до 12 ч.
+ """
+ until = datetime.now(UTC) + timedelta(hours=6)
+ db = FakeSession(
+ [_proxy(1), _proxy(2)],
+ bans=[
+ {
+ "proxy_id": 1,
+ "source": "cian",
+ "banned_until": until,
+ "ban_count": 1,
+ "reason": "banned:cian", # боевой сбор: Циан отдал заглушку
+ }
+ ],
+ )
+ _patch_probes(
+ monkeypatch,
+ {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")},
+ )
+
+ counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
+
+ ban = db._ban(1, "cian")
+ assert ban is not None
+ assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора"
+ assert ban["ban_count"] == 1, "два события разного рода посчитаны одной эскалацией"
+ assert ban["banned_until"] == until, "чужой срок проба не пересчитывает (может и укоротить)"
+ assert counters["pair_banned"] == 0, "счётчик не должен объявлять баном то, чего не записал"
+
+
+async def test_live_ban_takes_over_the_probe_row(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Зеркало намеренно НЕ симметрично: боевой сбор строку пробы перехватывает.
+
+ Его вердикт сильнее — площадка отбила нас именно сейчас, — пара остаётся забаненной,
+ а метка становится точнее. Если запретить и ему, строка останется за пробой, и её же
+ зелёный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально.
+ """
+ db = FakeSession(
+ [_proxy(1), _proxy(2)],
+ bans=[
+ {
+ "proxy_id": 1,
+ "source": "cian",
+ "banned_until": datetime.now(UTC) + timedelta(hours=6),
+ "ban_count": 1,
+ "reason": "probe:browser",
+ }
+ ],
+ )
+
+ proxy_pool.mark_banned(db, 1, source="cian") # type: ignore[arg-type]
+
+ assert db._ban(1, "cian")["reason"] == "banned:cian"
+
+ # …и с этой минуты зелёная проба его не снимет — ради чего перехват и нужен.
+ _patch_probes(monkeypatch, {})
+ await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
+ assert db._ban(1, "cian") is not None
+
+
# ── 5-6. чужие отказы ────────────────────────────────────────────────────────
From 12c189ac27f8e3f0d6898d5560e0545a09065237 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 08:29:26 +0000
Subject: [PATCH 041/124] =?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 042/124] =?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 043/124] =?UTF-8?q?fix(tradein/newbuilding):=20=D1=81?=
=?UTF-8?q?=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20=D0=B7=D0=B0=D0=BF?=
=?UTF-8?q?=D0=B8=D1=81=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0?=
=?UTF-8?q?=D1=8E=D1=82=20=D0=B2=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D1=83=20?=
=?UTF-8?q?=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?=
=?UTF-8?q?=D0=B5=20(#2807)=20(#2809)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tradein-mvp/backend/app/api/v1/admin.py | 5 +-
.../app/tasks/newbuilding_enrich_backfill.py | 63 +++---
.../tasks/test_newbuilding_enrich_backfill.py | 47 +++--
.../tests/test_2767_newbuilding_parse_miss.py | 4 +-
.../tests/test_2807_write_counters_honesty.py | 193 ++++++++++++++++++
.../scraper_kit/providers/cian/newbuilding.py | 63 +++++-
6 files changed, 322 insertions(+), 53 deletions(-)
create mode 100644 tradein-mvp/backend/tests/test_2807_write_counters_honesty.py
diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
index 89d6ef8d..856a7213 100644
--- a/tradein-mvp/backend/app/api/v1/admin.py
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -1961,8 +1961,9 @@ async def scrape_cian_newbuilding(
saved = False
if house_id is not None:
- # save_newbuilding_enrichment — sync (def, returns None); await на sync-функции
- # раньше поднимал TypeError на любом вызове с house_id.
+ # save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции
+ # раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт
+ # записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения.
save_newbuilding_enrichment(db, house_id, enrichment)
saved = True
diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
index abd51d13..632ec801 100644
--- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
+++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
@@ -109,10 +109,17 @@ class NewbuildingEnrichBackfillResult:
failed_fetch: int = 0 # fetch returned None / raised
failed_save: int = 0 # save raised after a good fetch
- # Row-level deltas (how much actually landed).
- price_dynamics_rows: int = 0
- reliability_rows: int = 0
- review_rows: int = 0
+ # Сколько РЕАЛЬНО записано, по словам самих писателей (#2807). Раньше здесь стоял
+ # прирост COUNT(*) по таблице до/после сохранения — то есть «выросла ли таблица», а
+ # не «сколько записали»: при ON CONFLICT DO UPDATE обновление даёт ноль, а у
+ # reliability ноль давал ещё и _dedup_reliability, схлопывающий дубль сразу после
+ # вставки. Ключи переименованы намеренно: у price_dynamics_rows/reliability_rows/
+ # review_rows в истории прогонов старый смысл, и молча поменять его под тем же
+ # именем — ровно тот дефект, ради которого правка и делается.
+ price_dynamics_inserted: int = 0 # новых точек динамики цен
+ price_dynamics_updated: int = 0 # существующих точек переписано свежей ценой
+ reliability_inserted: int = 0 # строк house_reliability_checks вставлено
+ review_upserted: int = 0 # отзывов записано (вставка+обновление, ключ ext_review_id)
duration_sec: float = field(default=0.0)
@@ -557,15 +564,15 @@ async def backfill_newbuilding_enrichment(
continue
# ── Save under a SAVEPOINT so one bad house can't poison the batch ──
- # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally,
- # so we snapshot the row counts BEFORE and recompute the delta AFTER its commit
- # rather than relying on the nested transaction staying open.
- pd_before, rc_before, rv_before = _house_enrichment_counts(db, house_id)
+ # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally.
+ # COUNT(*) до сохранения нужен ТОЛЬКО для had_reliability (дедуп ниже): сколько
+ # записано, теперь сообщают сами писатели, а не разница COUNT'ов (#2807).
+ _, rc_before, _ = _house_enrichment_counts(db, house_id)
try:
had_reliability = rc_before > 0
# 1) price_dynamics + reliability + houses UPDATE (existing, commits inside).
- save_newbuilding_enrichment(db, house_id, enrichment)
+ saved = save_newbuilding_enrichment(db, house_id, enrichment)
# 2) reviews — added here (save_newbuilding_enrichment skips them).
# SAVEPOINT around the review write so a malformed review can't lose the
@@ -601,16 +608,18 @@ async def backfill_newbuilding_enrichment(
sp.rollback()
logger.warning("reliability dedup failed house_id=%s: %s", house_id, dexc)
- pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id)
- result.price_dynamics_rows += max(0, pd_after - pd_before)
- result.reliability_rows += max(0, rc_after - rc_before)
- result.review_rows += max(0, rv_after - rv_before)
+ result.price_dynamics_inserted += saved.price_inserted
+ result.price_dynamics_updated += saved.price_updated
+ result.reliability_inserted += saved.reliability_inserted
+ result.review_upserted += review_written
result.succeeded += 1
logger.info(
- "enriched house_id=%s: +pd=%d +reliability=%d +reviews=%d (parsed reviews=%d)",
+ "enriched house_id=%s: динамика цен +%d новых / %d обновлено, "
+ "reliability +%d, отзывов записано %d (распознано %d)",
house_id,
- max(0, pd_after - pd_before),
- max(0, rc_after - rc_before),
+ saved.price_inserted,
+ saved.price_updated,
+ saved.reliability_inserted,
review_written,
len(enrichment.reviews),
)
@@ -629,8 +638,8 @@ async def backfill_newbuilding_enrichment(
result.duration_sec = time.time() - t0
logger.info(
"newbuilding-enrich backfill done: processed=%d ok=%d skip=%d resolved=%d "
- "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d "
- "| %.1fs",
+ "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / "
+ "%d обновлено, reliability +%d, отзывов %d | %.1fs",
result.processed,
result.succeeded,
result.skipped_already_enriched,
@@ -638,9 +647,10 @@ async def backfill_newbuilding_enrichment(
result.failed_resolve,
result.failed_fetch,
result.failed_save,
- result.price_dynamics_rows,
- result.reliability_rows,
- result.review_rows,
+ result.price_dynamics_inserted,
+ result.price_dynamics_updated,
+ result.reliability_inserted,
+ result.review_upserted,
result.duration_sec,
)
return result
@@ -787,8 +797,8 @@ async def run_newbuilding_enrich(
)
logger.info(
"scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d "
- "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d "
- "| pending=%d %.1fs",
+ "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / "
+ "%d обновлено, reliability +%d, отзывов %d | pending=%d %.1fs",
run_id,
result.processed,
result.succeeded,
@@ -796,9 +806,10 @@ async def run_newbuilding_enrich(
result.failed_resolve,
result.failed_fetch,
result.failed_save,
- result.price_dynamics_rows,
- result.reliability_rows,
- result.review_rows,
+ result.price_dynamics_inserted,
+ result.price_dynamics_updated,
+ result.reliability_inserted,
+ result.review_upserted,
result.cian_houses_pending,
result.duration_sec,
)
diff --git a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py
index 4df0d729..95ae2c4a 100644
--- a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py
+++ b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py
@@ -19,7 +19,10 @@ _wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
-from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment # noqa: E402
+from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
+ NewbuildingEnrichment,
+ NewbuildingSaveCounts,
+)
from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
NewbuildingEnrichBackfillResult,
@@ -168,24 +171,37 @@ def _enrichment_with_everything(seed: int = 0) -> NewbuildingEnrichment:
def _fake_save_newbuilding_enrichment(db, house_id, enrichment):
- """Stand-in for the real saver: lands price_dynamics + reliability into FakeDB."""
+ """Stand-in for the real saver: lands price_dynamics + reliability into FakeDB.
+
+ Возвращает NewbuildingSaveCounts, как настоящий (#2807): вставку от обновления
+ различает сам писатель — снаружи по таблице их не отличить (UPSERT по dim_key).
+ """
+ inserted = updated = 0
for p in enrichment.realty_valuation_chart:
if p.get("price_per_sqm") is None:
continue
- db.price_dynamics.add(
- (
- house_id,
- p["month_date"],
- "cian_realty_valuation",
- p.get("room_count", "all"),
- p.get("prices_type", "price"),
- p.get("period", "halfYear"),
- )
+ key = (
+ house_id,
+ p["month_date"],
+ "cian_realty_valuation",
+ p.get("room_count", "all"),
+ p.get("prices_type", "price"),
+ p.get("period", "halfYear"),
)
+ if key in db.price_dynamics:
+ updated += 1
+ else:
+ inserted += 1
+ db.price_dynamics.add(key)
+ reliability = 0
for c in enrichment.reliability_checks:
if c.get("check_name") or c.get("check_status"):
db.reliability.append((house_id, "cian_nashdom"))
+ reliability += 1
db.commit()
+ return NewbuildingSaveCounts(
+ price_inserted=inserted, price_updated=updated, reliability_inserted=reliability
+ )
# ---------------------------------------------------------------------------
@@ -279,9 +295,10 @@ async def test_backfill_populates_all_three_tables() -> None:
assert len(db.price_dynamics) == 2 # 1 chart point × 2 houses
assert len(db.reliability) == 2
assert len(db.reviews) == 4 # 2 reviews × 2 houses
- assert result.price_dynamics_rows == 2
- assert result.reliability_rows == 2
- assert result.review_rows == 4
+ assert result.price_dynamics_inserted == 2
+ assert result.price_dynamics_updated == 0
+ assert result.reliability_inserted == 2
+ assert result.review_upserted == 4
@pytest.mark.asyncio
@@ -544,7 +561,7 @@ async def test_run_wrapper_marks_done_and_passes_params(monkeypatch: pytest.Monk
async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None):
# on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем.
seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec)
- return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_rows=2)
+ return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_inserted=2)
monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake_backfill)
monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None)
diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
index 81056835..fea61b82 100644
--- a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
+++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
@@ -185,7 +185,7 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None:
_stub_backfill(
monkeypatch,
NewbuildingEnrichBackfillResult(
- processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_rows=7
+ processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7
),
)
calls = _stub_finalisers(monkeypatch)
@@ -196,5 +196,5 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None:
assert counters["attempted"] == 10
assert counters["enriched"] == 3
assert counters["failed"] == 7
- assert counters["price_dynamics_rows"] == 7 # исходные счётчики на месте
+ assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте
assert counters["succeeded"] == 3
diff --git a/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py
new file mode 100644
index 00000000..69b93713
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py
@@ -0,0 +1,193 @@
+"""#2807: счётчик мерил прирост таблицы, а читался как «сколько записали».
+
+`newbuilding_enrich_backfill` считал свою работу разницей `COUNT(*)` до и после
+сохранения. Вставка в houses_price_dynamics идёт `ON CONFLICT … DO UPDATE`, поэтому
+обновление существующей точки давало ноль. Прод 10.08: прогон 3578 отчитался
+`price_dynamics_rows: 0`, обновив за своё окно **64 строки по 10 домам** — те самые,
+что вставил прогон 3563 накануне (у него в тех же counters стояло 64). Ноль читался
+как «динамика цен снова не пишется».
+
+Соседние счётчики врали в том же месте по своим причинам: `reliability_rows` обнулял
+`_dedup_reliability`, схлопывающий строку сразу после вставки, а `review_rows`
+игнорировал число, которое `_save_cian_reviews` УЖЕ возвращал, в пользу разницы COUNT'ов.
+
+Фальсификация (см. прогон в PR): на коде до правки `test_second_pass_reports_updates`
+даёт `price_dynamics_rows == 0` при 64 переписанных точках — ровно прод-симптом.
+
+Отдельно проверяется, что правка НЕ ослабила сторожа нулевого результата: он смотрит на
+`attempted`/`enriched`/`gone`/`blocked` (#2695), а не на счётчики записи, и прогон,
+который ничего не обогатил, обязан остаться 'failed' при любых числах в `*_written`.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from unittest.mock import MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+sys.modules.setdefault("weasyprint", MagicMock())
+
+from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
+ NewbuildingSaveCounts,
+ save_newbuilding_enrichment,
+)
+
+from app.services import scrape_runs as runs_mod # noqa: E402
+from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
+ NewbuildingEnrichBackfillResult,
+)
+
+# Прод-масштаб прогона 3578: 10 домов × 64/10 точек. Держим ровно 64, чтобы число в
+# тесте совпадало с числом в задаче.
+PROD_POINTS = 64
+
+
+class _UpsertDB:
+ """Сессия, у которой houses_price_dynamics уже населена (второй проход).
+
+ `RETURNING (xmax = 0)` возвращает False на конфликте — это и есть «обновили».
+ """
+
+ def __init__(self, *, already_present: bool) -> None:
+ self.already_present = already_present
+ self.price_writes = 0
+ self.reliability_writes = 0
+ self.committed = False
+
+ def execute(self, statement, params=None):
+ sql = str(statement)
+ res = MagicMock()
+ if "INSERT INTO houses_price_dynamics" in sql:
+ self.price_writes += 1
+ assert "RETURNING (xmax = 0)" in sql, "писатель обязан различать вставку и update"
+ res.fetchone.return_value = (not self.already_present,)
+ return res
+ if "INSERT INTO house_reliability_checks" in sql:
+ self.reliability_writes += 1
+ res.fetchone.return_value = None
+ return res
+
+ def commit(self) -> None:
+ self.committed = True
+
+
+def _enrichment(points: int):
+ from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment
+
+ return NewbuildingEnrichment(
+ cian_internal_house_id=1,
+ cian_zhk_url="https://zhk-x.cian.ru/",
+ name="ЖК Тест",
+ realty_valuation_chart=[
+ {
+ "month_date": f"2026-{(i % 12) + 1:02d}-01",
+ "room_count": "all",
+ "prices_type": "price",
+ "period": "halfYear",
+ "price_per_sqm": 150000.0 + i,
+ }
+ for i in range(points)
+ ],
+ reliability_checks=[{"check_name": "Надёжный застройщик", "check_status": "reliable"}],
+ reviews=[],
+ )
+
+
+# ── 1. Писатель различает вставку и обновление ───────────────────────────────
+
+
+def test_first_pass_reports_inserts() -> None:
+ db = _UpsertDB(already_present=False)
+ counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS))
+ assert counts.price_inserted == PROD_POINTS
+ assert counts.price_updated == 0
+ assert counts.reliability_inserted == 1
+ assert db.price_writes == PROD_POINTS
+
+
+def test_second_pass_reports_updates() -> None:
+ """Прод-симптом: те же 64 точки, ничего нового — но записаны все 64.
+
+ До правки этот прогон отчитывался нулём по всем трём счётчикам.
+ """
+ db = _UpsertDB(already_present=True)
+ counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS))
+ assert counts.price_inserted == 0
+ assert counts.price_updated == PROD_POINTS
+ assert counts.price_written == PROD_POINTS
+ assert db.price_writes == PROD_POINTS
+
+
+def test_nothing_to_write_stays_zero() -> None:
+ """Встречная проверка: пустой график — ноль и во «вставлено», и в «обновлено»."""
+ db = _UpsertDB(already_present=True)
+ counts = save_newbuilding_enrichment(db, 42, _enrichment(0))
+ assert (counts.price_inserted, counts.price_updated, counts.price_written) == (0, 0, 0)
+
+
+def test_points_without_price_are_not_counted_as_written() -> None:
+ """Точка без price_per_sqm пропускается писателем — и не попадает в счёт."""
+ enrichment = _enrichment(2)
+ enrichment.realty_valuation_chart[0]["price_per_sqm"] = None
+ db = _UpsertDB(already_present=False)
+ counts = save_newbuilding_enrichment(db, 42, enrichment)
+ assert counts.price_written == 1
+ assert db.price_writes == 1
+
+
+# ── 2. Сторож нулевого результата не ослаблен ────────────────────────────────
+
+
+def _finalize(counters: dict[str, int]) -> str:
+ """Прогнать counters через боевой финализатор и вернуть выбранный статус."""
+ chosen: dict[str, str] = {}
+ with (
+ patch.object(runs_mod, "mark_done", lambda *a, **k: chosen.setdefault("s", "done")),
+ patch.object(runs_mod, "mark_failed", lambda *a, **k: chosen.setdefault("s", "failed")),
+ patch.object(runs_mod, "mark_banned", lambda *a, **k: chosen.setdefault("s", "banned")),
+ ):
+ runs_mod.mark_backfill_finished(MagicMock(), 1, counters, source="newbuilding_enrich")
+ return chosen["s"]
+
+
+def test_watchdog_still_fails_a_run_that_enriched_nothing() -> None:
+ """Прогон без обогащений остаётся 'failed', сколько бы записей ни насчитали.
+
+ Числа записи в решение сторожа не входят вовсе — он судит по attempted/enriched.
+ Если бы входили, честный счётчик «обновлено» превратил бы холостой прогон в успех.
+ """
+ result = NewbuildingEnrichBackfillResult(
+ processed=25,
+ succeeded=0,
+ failed_fetch=25,
+ price_dynamics_updated=PROD_POINTS, # «что-то писали» — но никого не обогатили
+ )
+ assert _finalize(result.to_backfill_counters()) == "failed"
+
+
+def test_watchdog_verdict_ignores_the_new_keys() -> None:
+ """Явно: добавление/убирание новых ключей не двигает вердикт ни в одну сторону."""
+ base = {"attempted": 25, "enriched": 3, "failed": 22}
+ assert _finalize(dict(base)) == "done"
+ assert _finalize({**base, "price_dynamics_inserted": 0, "price_dynamics_updated": 0}) == "done"
+ zero = {"attempted": 25, "enriched": 0, "failed": 25}
+ assert _finalize(dict(zero)) == "failed"
+ assert _finalize({**zero, "price_dynamics_updated": 999}) == "failed"
+
+
+def test_counters_carry_both_numbers_into_the_run() -> None:
+ """В scrape_runs.counters уезжают ОБА числа — ноль одного больше не читается как ноль."""
+ counters = NewbuildingEnrichBackfillResult(
+ processed=25, succeeded=25, price_dynamics_updated=PROD_POINTS
+ ).to_backfill_counters()
+ assert counters["price_dynamics_inserted"] == 0
+ assert counters["price_dynamics_updated"] == PROD_POINTS
+ # Старые имена не должны остаться: у них в истории прогонов другой смысл.
+ assert "price_dynamics_rows" not in counters
+ assert "reliability_rows" not in counters
+ assert "review_rows" not in counters
+
+
+def test_save_counts_written_is_the_sum() -> None:
+ assert NewbuildingSaveCounts(price_inserted=3, price_updated=4).price_written == 7
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py
index 4211f73f..58bede93 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py
@@ -594,11 +594,38 @@ def _extract_nested_offers(offers_state: dict[str, Any]) -> list[dict[str, Any]]
# ---- save helpers ----
+@dataclass(frozen=True)
+class NewbuildingSaveCounts:
+ """Что прогон РЕАЛЬНО записал — вставил и обновил отдельно (#2807).
+
+ Заводится потому, что вызывающий мерил свою работу разницей ``COUNT(*)`` по таблице
+ до и после сохранения. Это прирост ЧИСЛА СТРОК, а не число записанных точек: у
+ houses_price_dynamics вставка идёт ``ON CONFLICT … DO UPDATE``, поэтому обновление
+ уже существующей точки даёт ноль. Прод 10.08: прогон 3578 отчитался
+ ``price_dynamics_rows: 0``, обновив за своё окно 64 строки по 10 домам (их вставил
+ прогон 3563 накануне) — ноль читался как «динамика цен не пишется».
+
+ Единственный, кто знает разницу, — сам писатель: ``RETURNING (xmax = 0)`` отличает
+ вставку от обновления (та же идиома, что в
+ ``backend/app/services/scrapers/gisogd66.py``). Поэтому число возвращается отсюда, а
+ не восстанавливается снаружи по таблице.
+ """
+
+ price_inserted: int = 0
+ price_updated: int = 0
+ reliability_inserted: int = 0
+
+ @property
+ def price_written(self) -> int:
+ """Сколько точек динамики прошло через запись (вставка + обновление)."""
+ return self.price_inserted + self.price_updated
+
+
def save_newbuilding_enrichment(
db: Any,
house_id: int,
enrichment: NewbuildingEnrichment,
-) -> None:
+) -> NewbuildingSaveCounts:
"""Persist NewbuildingEnrichment to DB.
Steps:
@@ -606,6 +633,9 @@ def save_newbuilding_enrichment(
2. UPDATE houses with Cian metadata (incl. cian_zhk_url if present)
3. INSERT INTO houses_price_dynamics (chart points, ON CONFLICT DO UPDATE)
4. INSERT INTO house_reliability_checks (overall + details)
+
+ Returns NewbuildingSaveCounts — вставлено/обновлено раздельно (#2807). Вызывающие,
+ которым счёт не нужен (SERP-sweep, admin re-enrich), просто игнорируют результат.
"""
from sqlalchemy import text
@@ -681,14 +711,18 @@ def save_newbuilding_enrichment(
# 3. INSERT houses_price_dynamics
# UNIQUE constraint: houses_price_dynamics_dim_key
# (house_id, source, room_count, prices_type, period, month_date) — per migration 029
- chart_saved = 0
+ price_inserted = 0
+ price_updated = 0
for point in enrichment.realty_valuation_chart:
if point.get("price_per_sqm") is None:
continue
room_count = point.get("room_count") or "all"
prices_type = point.get("prices_type") or "price"
period = point.get("period") or "halfYear"
- db.execute(
+ # RETURNING (xmax = 0): у только что вставленной строки xmax равен нулю, у
+ # обновлённой конфликтом — id транзакции. Без этого «вставили» и «обновили»
+ # снаружи неразличимы, и обновление читается как «ничего не записали» (#2807).
+ written = db.execute(
text("""
INSERT INTO houses_price_dynamics (
house_id, month_date, source,
@@ -705,6 +739,7 @@ def save_newbuilding_enrichment(
ON CONFLICT ON CONSTRAINT houses_price_dynamics_dim_key DO UPDATE SET
price_per_sqm = EXCLUDED.price_per_sqm,
recorded_at = NOW()
+ RETURNING (xmax = 0) AS is_insert
"""),
{
"hid": house_id,
@@ -714,12 +749,16 @@ def save_newbuilding_enrichment(
"pd": period,
"pps": point["price_per_sqm"],
},
- )
- chart_saved += 1
+ ).fetchone()
+ if written is not None and written[0]:
+ price_inserted += 1
+ else:
+ price_updated += 1
# 4. INSERT house_reliability_checks (stores overall check + details array)
# Schema (025): (house_id, check_status, check_name, details jsonb, source, recorded_at)
# No UNIQUE constraint — caller should manage duplicates if needed
+ reliability_inserted = 0
for check in enrichment.reliability_checks:
if not check.get("check_name") and not check.get("check_status"):
continue
@@ -743,15 +782,23 @@ def save_newbuilding_enrichment(
"det": json.dumps(check.get("details") or [], ensure_ascii=False),
},
)
+ reliability_inserted += 1
db.commit()
logger.info(
- "Cian newbuilding saved house_id=%s (chart=%d points, reliability=%d checks, mc_id=%s)",
+ "Cian newbuilding saved house_id=%s (chart: +%d new / %d updated, "
+ "reliability=%d checks, mc_id=%s)",
house_id,
- chart_saved,
- len(enrichment.reliability_checks),
+ price_inserted,
+ price_updated,
+ reliability_inserted,
mc_id,
)
+ return NewbuildingSaveCounts(
+ price_inserted=price_inserted,
+ price_updated=price_updated,
+ reliability_inserted=reliability_inserted,
+ )
async def resolve_cian_zhk_url(
From 272abac4d2895c814cec55ebdd01edc19c1fc46e Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 08:51:14 +0000
Subject: [PATCH 044/124] =?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 045/124] =?UTF-8?q?fix(tradein/avito):=20=D1=80=D0=B5?=
=?UTF-8?q?=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=BE=D0=BC=D0=B0=20?=
=?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D1=83?=
=?UTF-8?q?=D0=B5=D0=B7=D0=B6=D0=B0=D1=82=D1=8C=20=D0=B2=20=D0=B0=D0=B4?=
=?UTF-8?q?=D1=80=D0=B5=D1=81=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5?=
=?UTF-8?q?=D0=BD=D0=B8=D1=8F=20(#2814)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Авито с 27.07 рендерит рейтинг и число отзывов внутри того же
в
data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
отзыва». Прод 2026-08-10: 1 123 активных объявления с таким адресом, и у 1 123 из
1 123 нет координат — доля 100%, 711 из них геокодер уже пробовал. Контроль в тех
же данных: у чистых адресов координаты есть у 4 766 из 5 715 (83%).
Строка без geom молча выпадает из comp-пула: Tier W отбирает через ST_DWithin, а
NULL не проходит предикат и нигде не считается. 2 446 из 3 270 безкоординатных
попадают в свежий пул аналогов — 12.7% аналогов невидимы радиусному поиску.
Режем по «·», за которой идёт ЦИФРА (рейтинг «·4,9», счётчик «·2 отзыва»). По
любой «·» нельзя: разделитель района пишется «, 59 · р-н Академический» — за
точкой буква, и этот хвост _deglue_house_marker намеренно сохраняет (#1773).
Тест проверяет обе стороны плюс три прежних хвоста (CSS, метро, «от N мин.»).
Чинит только новые вставки: base.py пишет address = COALESCE(listings.address,
EXCLUDED.address), адрес при конфликте не перезаписывается осознанно (#2777).
Бэкфилл 1 123 существующих строк вынесен в #2814 для database-expert.
---
.../test_avito_clean_address_rating_tail.py | 59 +++++++++++++++++++
.../src/scraper_kit/providers/avito/serp.py | 10 +++-
2 files changed, 68 insertions(+), 1 deletion(-)
create mode 100644 tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py
diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py
new file mode 100644
index 00000000..2e9da4bf
--- /dev/null
+++ b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py
@@ -0,0 +1,59 @@
+"""Адрес Авито не должен утаскивать за собой рейтинг дома и число отзывов.
+
+Прод 2026-08-10: 1 123 активных объявления Авито с адресом вида
+«ул. Ткачей,17·5,0 · 4 отзыва», и у 1 123 из 1 123 нет координат — доля 100%.
+У объявлений с чистым адресом координаты есть у 4 766. Хвост появился 27.07.2026,
+когда Авито начал рендерить рейтинг внутри того же
в item-location.
+"""
+
+from __future__ import annotations
+
+import pytest
+from scraper_kit.providers.avito.serp import _clean_address
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ # Живые строки с прода — рейтинг + счётчик отзывов.
+ ("Авиационная ул.,10·4,9 · 11 отзывов", "Авиационная ул.,10"),
+ ("ул. 8 Марта,204Г/2·4,3 · 3 отзыва", "ул. 8 Марта,204Г/2"),
+ ("ул. Академика Шварца,18к2·1 отзыв", "ул. Академика Шварца,18к2"),
+ # Только счётчик, без рейтинга.
+ ("ул. Фурманова,59·2 отзыва", "ул. Фурманова,59"),
+ # Пробелы вокруг разделителя.
+ ("ул. Ткачей, 17 · 5,0 · 4 отзыва", "ул. Ткачей, 17"),
+ ],
+)
+def test_rating_tail_stripped(raw: str, expected: str) -> None:
+ assert _clean_address(raw) == expected
+
+
+def test_district_marker_after_dot_survives() -> None:
+ """Режем «·» только перед цифрой — район за точкой остаётся на месте.
+
+ Иначе фикс рейтинга съел бы разделитель района, который _deglue_house_marker
+ намеренно восстанавливает (#1773).
+ """
+ assert (
+ _clean_address("улица Вильгельма де Геннина, 59 · р-н Академический")
+ == "улица Вильгельма де Геннина, 59 · р-н Академический"
+ )
+
+
+def test_clean_address_unchanged() -> None:
+ assert _clean_address("с. Новоалексеевское, ул. 8 Марта,35") == (
+ "с. Новоалексеевское, ул. 8 Марта,35"
+ )
+
+
+def test_existing_noise_still_stripped() -> None:
+ """Регрессия: старые хвосты (CSS/метро/«от N мин.») режутся как раньше."""
+ assert _clean_address("ул. Токарей, 56к1Площадь 1905 года.css-39hgr0{fill:red}") == (
+ "ул. Токарей, 56к1"
+ )
+ assert _clean_address("ул. Малышева, 1.css-xxx{color:blue}") == "ул. Малышева, 1"
+ # #1773: слипшийся маркер района по-прежнему расклеивается.
+ assert _clean_address("ул. Евгения Савкова, 29р-н Академический") == (
+ "ул. Евгения Савкова, 29, р-н Академический"
+ )
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
index 484403c3..3c3d717a 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
@@ -2097,8 +2097,16 @@ def _avito_room_label(name: str) -> str:
_CSS_NOISE_RE = re.compile(r"\.?css-[a-z0-9_-]+\s*\{[^}]*\}", flags=re.I)
+# Хвост «·<цифра>» — рейтинг дома и число отзывов, которые Авито с 27.07.2026 рендерит
+# ВНУТРИ того же
в data-marker="item-location": «ул. Ткачей,17·5,0 · 4 отзыва».
+# Прод 2026-08-10: 1 123 активных объявления с таким адресом, и у 1 123 из 1 123 нет
+# координат (доля 100%) — ни один геокодер такую строку не берёт. Для сравнения, у
+# объявлений с чистым адресом координаты есть у 4 766.
+# Режем по «·», ЗА КОТОРОЙ ИДЁТ ЦИФРА (рейтинг «·4,9» либо счётчик «·2 отзыва»), а не
+# по любой «·»: разделитель района у площадок пишется как «, 59 · р-н Академический» —
+# за точкой буква, и такой хвост _deglue_house_marker намеренно сохраняет.
_NOT_ADDRESS_TAIL_RE = re.compile(
- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+)",
+ r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)",
flags=re.I,
)
From a227877905515b86e7a95bfd4de42ba3b2fa164f Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 09:36:23 +0000
Subject: [PATCH 046/124] =?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 047/124] =?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 048/124] =?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 049/124] =?UTF-8?q?fix(tradein/dedup):=20=D0=BE=D1=81?=
=?UTF-8?q?=D1=82=D0=B0=D1=82=D0=BE=D0=BA=20=D1=81=D1=85=D0=BB=D0=BE=D0=BF?=
=?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BC=D0=BE?=
=?UTF-8?q?=D0=B2=20=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=81?=
=?UTF-8?q?=D1=8F=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D1=8F=D0=B5=D0=BC=D1=8B?=
=?UTF-8?q?=D0=BC=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=D0=BC,=20=D0=B0=20?=
=?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=BE=D0=B9=20(#2?=
=?UTF-8?q?690)=20(#2820)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../backend/app/services/house_dedup_merge.py | 229 +++++++++++++++---
.../backend/tests/test_house_dedup_merge.py | 90 +++++++
2 files changed, 290 insertions(+), 29 deletions(-)
diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py
index 0f244b97..511386a1 100644
--- a/tradein-mvp/backend/app/services/house_dedup_merge.py
+++ b/tradein-mvp/backend/app/services/house_dedup_merge.py
@@ -13,8 +13,10 @@ WHAT this is:
pipeline, run inside ONE transaction so a crash leaves the table untouched.
Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address
- COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod —
- confirmed in migration 040 — so address is the real building key). The clean source matters:
+ COALESCE(short_address, full_address, address) — the address is the only building key we
+ have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on
+ prod», is no longer true — 2 648 of 9 179 rows carry one — and the conclusion no longer
+ rests on it). The clean source matters:
`address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр»
→ canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66»
(→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon
@@ -116,6 +118,43 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
asymmetry — merge allowed without a proximity check — was invisible in data before; now
«how many merges happened beyond N metres, on which key» is one query.
+KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
+ #2690 asked for a cluster key that does not come from the normalized address, so that two
+ rows merge on two independent statements of identity rather than one restated twice. Every
+ field `houses` carries was checked against the live table. None qualifies:
+
+ cadastral_number 2 648 filled, ALL 2 648 values DISTINCT → collapses nothing. Provenance:
+ all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are
+ DaData's answer to our address string, not a second observation of the
+ building. (The other cadastre we hold, listings.building_cadastral_number,
+ is the KNN geo-nearest hint — 20.1% of its values cover >1 ГАР building;
+ #2674 refused it as an identity key and that stands.)
+ house_fias_id 3 678 filled, ALL DISTINCT → the FIAS pass merges 0 rows today. Same
+ DaData provenance.
+ gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share
+ the canon (the guid restates it), 17 do not — and 5 of those 17 are
+ >250 m apart, worst 5 064 km. Still circular, still noisy.
+ zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the
+ loader sets it WHERE gar_house_guid = , i.e. it IS the ГАР guid for
+ 4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback
+ — keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon,
+ 193 come through the fallback, and 30 of the 31 pairs >250 m apart do too.
+ source+ext_house_id, cian_internal_house_id, yandex_jk_id
+ distinct by construction / 39 / 0 rows → nothing to cluster.
+ coordinates a real independent observation, but not an IDENTITY: neighbours share a
+ yard. It is already used the only way it can be — as the guard.
+ year_built+total_floors
+ a FALSE witness, not a corroborator: of the 391 same-canon pairs the
+ guard cannot judge, only 18 agree on both fields (357 have a NULL), while
+ 306 pairs the guard rejected at >250 m DO agree — it would confirm merges
+ that are provably wrong.
+
+ Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the
+ canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the
+ residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the
+ guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart
+ (median 1 084 m) — those are not duplicates at all, the canon key is wrong about them.
+
IDEMPOTENCY:
Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the
mapping is empty → every statement touches 0 rows → no-op. Re-running is safe.
@@ -165,6 +204,14 @@ _COMPLETENESS_EXPR = """
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
+#
+# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле —
+# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина
+# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния).
+# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого
+# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить
+# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом
+# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей».
_KEEPER_ORDER = f"""
(h.geom IS NOT NULL) DESC,
listing_cnt DESC NULLS LAST,
@@ -199,37 +246,17 @@ _CANON_KEY_EXPR = """
"""
-def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
- """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
+def _ranked_cte(cluster_key_case: str) -> str:
+ """Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row.
- Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
- are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
- row shares one fias by construction).
-
- apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
- - CANON pass → True: the canon strips город/район, so same-street-number buildings in
- different region-66 towns share a canon; the guard stops the cross-town over-merge.
- - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
- proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
- geom-first keeper rule simultaneously repairs the broken coordinate).
+ Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census
+ (`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census
+ built from its own copy of the clustering would drift from the pass it describes and the
+ drift would be invisible (it is the same class of error as #2690's cluster key: two
+ expressions that look alike and are not).
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
"""
- geo_guard = (
- """
- -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
- -- different buildings sharing a street+number canon («Ленина 5» in different region-66
- -- towns) collapse to one cluster_key. A loser merges only when geographically next to the
- -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
- -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
- -- geom on either side, → left as separate rows (conservative — never over-merges).
- AND keeper_geom IS NOT NULL
- AND loser_geom IS NOT NULL
- AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
- if apply_geo_guard
- else ""
- )
return f"""
- CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
WITH clustered AS (
SELECT
id,
@@ -281,7 +308,41 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
FROM dup_houses dh
JOIN houses h ON h.id = dh.id
LEFT JOIN listing_counts lc ON lc.house_id = dh.id
+ )"""
+
+
+def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
+ """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
+
+ Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
+ are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
+ row shares one fias by construction).
+
+ apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
+ - CANON pass → True: the canon strips город/район, so same-street-number buildings in
+ different region-66 towns share a canon; the guard stops the cross-town over-merge.
+ - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
+ proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
+ geom-first keeper rule simultaneously repairs the broken coordinate).
+ `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
+ """
+ geo_guard = (
+ """
+ -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
+ -- different buildings sharing a street+number canon («Ленина 5» in different region-66
+ -- towns) collapse to one cluster_key. A loser merges only when geographically next to the
+ -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
+ -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
+ -- geom on either side, → left as separate rows (conservative — never over-merges).
+ AND keeper_geom IS NOT NULL
+ AND loser_geom IS NOT NULL
+ AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
+ if apply_geo_guard
+ else ""
)
+ return f"""
+ CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
+ {_ranked_cte(cluster_key_case)}
-- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but
-- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon
-- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per
@@ -314,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
+# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
+#
+# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY.
+# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered
+# out is counted here, bucketed by the reason it survived.
+#
+# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there
+# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a
+# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the
+# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821.
+#
+# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard
+# rejected» are opposite facts:
+# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN.
+# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are
+# NOT duplicates — the canon key is wrong about them (prod 2026-08-10:
+# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt.
+# residual_cross_fias — provably different buildings (two different ФИАС UUIDs).
+# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real
+# run; non-zero is a tripwire on the pass itself, not a census entry.
+# residual_listings is the user-visible size of the remainder (listings hanging on those rows).
+_RESIDUAL_SQL = text(
+ f"""
+ {_ranked_cte(_CANON_KEY_EXPR)}
+ SELECT
+ count(*) FILTER (WHERE rn > 1) AS residual_rows,
+ COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings,
+ count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias,
+ count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL)
+ AS residual_no_geom,
+ count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far,
+ count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250)
+ AS residual_mergeable
+ FROM (
+ SELECT rn,
+ COALESCE(lc.listing_cnt, 0) AS lcnt,
+ CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
+ THEN ST_DistanceSphere(loser_geom, keeper_geom)
+ END AS dist,
+ (NULLIF(loser_fias, '') IS NOT NULL
+ AND NULLIF(keeper_fias, '') IS NOT NULL
+ AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias
+ FROM ranked
+ LEFT JOIN listing_counts lc ON lc.house_id = ranked.id
+ ) r
+ """
+)
+
# Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op.
_STEPS: list[tuple[str, str]] = [
# ── Plain re-point (no UNIQUE on the FK column) ───────────────────────────
@@ -726,6 +835,15 @@ class DedupMergeResult:
listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper
children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children
children_repointed: int = 0 # survivor child rows moved loser→keeper
+ # Residual census (#2690): same-canon rows STILL in the table after this run, by reason.
+ # Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with,
+ # so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL.
+ residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets)
+ residual_listings: int = 0 # listings hanging on them (the user-visible size)
+ residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates
+ residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building
+ residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings
+ residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0
dry_run: bool = False
duration_sec: float = field(default=0.0)
@@ -736,6 +854,12 @@ class DedupMergeResult:
"listings_repointed": self.listings_repointed,
"children_deleted": self.children_deleted,
"children_repointed": self.children_repointed,
+ "residual_rows": self.residual_rows,
+ "residual_listings": self.residual_listings,
+ "residual_no_geom": self.residual_no_geom,
+ "residual_far": self.residual_far,
+ "residual_cross_fias": self.residual_cross_fias,
+ "residual_mergeable": self.residual_mergeable,
"dry_run": int(self.dry_run),
"duration_sec": int(self.duration_sec),
}
@@ -853,6 +977,49 @@ def _run_merge_pass(
db.execute(_BACKFILL_ALIASES_SQL)
+def _measure_residual(db: Session, result: DedupMergeResult) -> None:
+ """Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690).
+
+ Read-only; runs after both passes, so it describes the table as the run leaves it (under
+ dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of
+ `_run_merge_pass` because the census is about the CANON key only and must be taken once per
+ call, not once per pass.
+
+ Never fails the merge: the merge itself is the product, the census is instrumentation, and a
+ census that can abort a committed-by-now transaction would be worse than a missing number.
+ """
+ try:
+ rows = db.execute(_RESIDUAL_SQL).all()
+ except Exception:
+ logger.exception("merge_duplicate_houses: residual census failed — counters left at 0")
+ return
+ if not rows:
+ return
+ r = rows[0]
+ result.residual_rows = int(r.residual_rows or 0)
+ result.residual_listings = int(r.residual_listings or 0)
+ result.residual_no_geom = int(r.residual_no_geom or 0)
+ result.residual_far = int(r.residual_far or 0)
+ result.residual_cross_fias = int(r.residual_cross_fias or 0)
+ result.residual_mergeable = int(r.residual_mergeable or 0)
+ logger.info(
+ "merge_duplicate_houses: residual rows=%d listings=%d "
+ "(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)",
+ result.residual_rows,
+ result.residual_listings,
+ result.residual_no_geom,
+ result.residual_far,
+ result.residual_cross_fias,
+ result.residual_mergeable,
+ )
+ if result.residual_mergeable:
+ logger.warning(
+ "merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass "
+ "left work on the table (expected 0)",
+ result.residual_mergeable,
+ )
+
+
def merge_duplicate_houses(
db: Session,
*,
@@ -908,6 +1075,10 @@ def merge_duplicate_houses(
result=result,
)
+ # Census of what is LEFT (read-only). Runs before the no-op early return on purpose:
+ # a run that merged nothing is exactly the run whose remainder needs a number.
+ _measure_residual(db, result)
+
if result.losers_deleted == 0:
# Clean table — both passes empty. Roll back (we only opened temp tables).
db.rollback()
diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py
index 63db7822..4355e323 100644
--- a/tradein-mvp/backend/tests/test_house_dedup_merge.py
+++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py
@@ -18,6 +18,7 @@ import inspect
import os
import re
from pathlib import Path
+from types import SimpleNamespace
from typing import Any
import pytest
@@ -486,9 +487,13 @@ class _FakeDB:
mapping_rows: list[_Row],
step_rowcount: int = 1,
fk_children: dict[str, str] | None = None,
+ residual: dict[str, int] | None = None,
+ residual_raises: bool = False,
):
self._mapping_rows = mapping_rows
self._step_rowcount = step_rowcount
+ self._residual = residual
+ self._residual_raises = residual_raises
self._mapping_served = False
# The catalog the FK-child guard reads; defaults to the real live set.
self._fk_children = _FK_CHILDREN if fk_children is None else fk_children
@@ -503,6 +508,12 @@ class _FakeDB:
return _FakeResult()
if "FROM pg_constraint" in sql:
return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()])
+ if "AS residual_rows" in sql: # residual census (#2690) — read-only, after both passes
+ if self._residual_raises:
+ raise RuntimeError("census exploded")
+ if self._residual is None:
+ return _FakeResult(rows=[])
+ return _FakeResult(rows=[SimpleNamespace(**self._residual)])
if "SELECT loser_id, keeper_id, norm_address" in sql:
# The service now runs TWO passes (fias, then canon). Model «fias pass found the
# duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards.
@@ -550,6 +561,85 @@ def test_dry_run_computes_counts_but_rolls_back() -> None:
assert db.rollbacks == 1
+# ── Residual census (#2690 п.2/п.4): остаток фиксируется числом, а не оценкой ──
+
+
+def test_residual_census_reuses_the_canon_mapping_prelude() -> None:
+ """Перепись остатка обязана считать РОВНО те строки, о которых рассуждает канон-проход.
+
+ Общий `_ranked_cte` — не косметика: собственная копия кластеризации разъехалась бы с
+ проходом, который она описывает, и разъезд был бы невидим (это тот же класс ошибки, что и
+ ключ в #2690 — два выражения, похожие друг на друга и не равные). RED до фикса: переписи
+ нет вовсе.
+ """
+ residual = _flat(str(hdm._RESIDUAL_SQL.text))
+ prelude = _flat(hdm._ranked_cte(hdm._CANON_KEY_EXPR))
+ assert prelude in residual
+ assert prelude in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR))
+
+
+def test_residual_census_keeps_silent_guard_apart_from_rejecting_guard() -> None:
+ """«Координат нет» и «дальше 250 м» — противоположные факты, в одну сумму их нельзя.
+
+ Первое означает, что страж не смог высказаться (остаток неизвестен), второе — что он
+ высказался по существу и дома РАЗНЫЕ (это вообще не дубли). Сумма из этих двух читается как
+ «долг», которого нет. Плюс: перепись НЕ применяет гео-фильтр — иначе она считала бы только
+ то, что и так слилось.
+ """
+ residual = _flat(str(hdm._RESIDUAL_SQL.text))
+ for bucket in (
+ "AS residual_rows",
+ "AS residual_listings",
+ "AS residual_no_geom",
+ "AS residual_far",
+ "AS residual_cross_fias",
+ "AS residual_mergeable",
+ ):
+ assert bucket in residual, bucket
+ # Гео-страж 250 м здесь — РАЗДЕЛИТЕЛЬ корзин, а не фильтр строк.
+ assert "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in residual
+ assert set(hdm.DedupMergeResult().to_counters()) >= {
+ "residual_rows",
+ "residual_listings",
+ "residual_no_geom",
+ "residual_far",
+ "residual_cross_fias",
+ "residual_mergeable",
+ }
+
+
+def test_residual_counters_reported_even_when_nothing_merged() -> None:
+ """Прогон, который не слил ничего, — ровно тот, чьему остатку нужно число."""
+ db = _FakeDB(
+ mapping_rows=[],
+ residual={
+ "residual_rows": 963,
+ "residual_listings": 1765,
+ "residual_no_geom": 326,
+ "residual_far": 568,
+ "residual_cross_fias": 8,
+ "residual_mergeable": 61,
+ },
+ )
+ out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
+ assert out["losers_deleted"] == 0
+ assert out["residual_rows"] == 963
+ assert out["residual_listings"] == 1765
+ assert out["residual_no_geom"] == 326
+ assert out["residual_far"] == 568
+ assert out["residual_cross_fias"] == 8
+ assert out["residual_mergeable"] == 61
+
+
+def test_residual_census_failure_never_breaks_the_merge() -> None:
+ """Перепись — приборы, слияние — продукт: упавший счётчик не отменяет коммит."""
+ db = _FakeDB(mapping_rows=[_Row(2, 1, "ул. мира, 10")], residual_raises=True)
+ out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
+ assert out["losers_deleted"] == 1
+ assert out["residual_rows"] == 0 # неизвестно — и это видно, а не выдумано
+ assert db.commits == 1
+
+
def test_real_merge_commits() -> None:
"""dry_run=False with dups → exactly one commit, no rollback."""
rows = [_Row(2, 1, "ул. мира, 10")]
From 20ec6a5d33cd83d04510456ee06708aa62cf01e1 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Mon, 10 Aug 2026 11:29:19 +0000
Subject: [PATCH 050/124] =?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 051/124] =?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 052/124] =?UTF-8?q?fix(tradein/estimate):=20=D0=BD=D0=B5?=
=?UTF-8?q?=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?=
=?UTF-8?q?=D1=8C=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D1=83=20=E2=80=94=20?=
=?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D1=8F=D1=82=D1=8C=20=D0=BF?=
=?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D0=B8=20=D1=87=D0=B5=D1=81?=
=?UTF-8?q?=D1=82=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?=
=?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20(#2823)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tradein-mvp/backend/app/schemas/trade_in.py | 35 ++
tradein-mvp/backend/app/services/estimator.py | 453 +++++++++++++++---
.../app/services/exporters/trade_in_pdf.py | 68 ++-
.../test_estimator_headline_sufficiency.py | 348 ++++++++++----
.../backend/tests/test_pdf_security.py | 80 ++++
.../tests/test_street_deals_endpoint.py | 58 +++
tradein-mvp/frontend/src/app/v2/page.tsx | 42 +-
.../src/components/trade-in/HeroSummary.tsx | 11 +-
.../src/components/trade-in/ListingsCard.tsx | 24 +-
.../trade-in/v2/LowConfidenceBanner.tsx | 112 +++++
.../components/trade-in/v2/ParamsPanel.tsx | 10 +-
.../src/components/trade-in/v2/SourcesMap.tsx | 18 +-
.../src/components/trade-in/v2/mappers.ts | 15 +-
.../src/components/trade-in/v2/ui-config.ts | 7 +-
tradein-mvp/frontend/src/types/trade-in.ts | 15 +
15 files changed, 1117 insertions(+), 179 deletions(-)
create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx
diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py
index 4f3d4996..b7cb3556 100644
--- a/tradein-mvp/backend/app/schemas/trade_in.py
+++ b/tradein-mvp/backend/app/schemas/trade_in.py
@@ -319,6 +319,41 @@ class AggregatedEstimate(BaseModel):
cv: float | None = None
source_counts: dict[str, int] = Field(default_factory=dict)
created_at: datetime | None = None
+ # ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
+ # #oblast-E priority RESTORED same day — see estimator.py module
+ # docstring for the full 3-way headline-source rule) ──────────────────
+ # Product requirement: an estimate is ALWAYS surfaced — a thin base sample
+ # (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
+ # estimator.estimate_quality() progressively relaxes the analog SEARCH
+ # (room-count adjacency → freshness window → novostroyki segment → radius)
+ # trying to grow the sample past the threshold; if it's STILL thin,
+ # _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
+ # thin listings median when one is available (restored #oblast-E
+ # priority — the Серов repro: 3 listings must not outrank 54 deals), and
+ # only falls back to the thin listings median itself when no corridor
+ # exists. Real refusal happens only at genuine zero (no listings AND no
+ # usable anchor/deals).
+ # relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
+ # ослабления, готовые к показу пользователю как честный дисклеймер рядом с
+ # confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
+ # не понадобился (обычный случай). Возможные значения (дословно, фронт
+ # может на них завязываться): "снят фильтр по году постройки",
+ # "учтены студии", "комнатность ±1", "объявления за 60 дней",
+ # "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
+ # "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
+ # deals corridor because the base listings sample was thin — a source
+ # SWITCH, not a search widening, but surfaced the same way).
+ # reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
+ # (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
+ # relaxations непусто (выборка набралась только ценой ослаблений); капается
+ # на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
+ # выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
+ # НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
+ # ограничение, каскад не переигрывается из сохранённых analogs). НЕ
+ # путать с `confidence` (Literal low/medium/high — старая метрика на
+ # основе уникальных адресов/IQR, см. её собственный докстринг выше).
+ relaxations: list[str] = Field(default_factory=list)
+ reliability: Literal["ok", "low", "very_low"] = "ok"
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
area_m2: float | None = None
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index eeced17f..01f8871a 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -185,6 +185,24 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3
# ИТОГОВОЙ выборке как headline-источнику.
HEADLINE_LISTINGS_MIN_N = 5
+# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live
+# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4
+# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34;
+# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10).
+# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового
+# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка
+# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять
+# параметры подбора (см. #oblast-F блок там), от наименее к наиболее
+# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент
+# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в
+# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт
+# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а
+# только помечает результат как низконадёжный.
+RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1
+LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней
+RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
+RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
+
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
@@ -2562,12 +2580,28 @@ class PricingResult:
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
cv: float | None = None
- # #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N
- # (headline suppressed, listings_clean deliberately left intact — see gate
- # comment above). Caller uses this to also keep the thin listings out of the
- # display `analogs` cards when no anchor overrides the headline. 0 = either
- # sufficient listings were used, or genuinely zero were found.
+ # #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже
+ # HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет
+ # headline/listings_clean — median/n_analogs остаются реальными, поле лишь
+ # маркирует «низкая надёжность» (confidence='low' + честный explanation,
+ # см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов
+ # вообще не нашлось.
listings_headline_thin_n: int = 0
+ # #oblast-E (restored priority, product correction 2026-08-10): True когда
+ # headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая
+ # выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен
+ # достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже).
+ # Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись
+ # «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на
+ # 'low' (не выше). False во всех остальных случаях, включая genuinely-zero
+ # listings deals-fallback (тот же блок, но без тонкой выборки позади).
+ deals_headline_due_to_thin_listings: bool = False
+
+
+def _analog_word_dative(n: int) -> str:
+ """Дательный падеж существительного «аналог» для confidence_explanation
+ тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам»."""
+ return "аналогу" if n == 1 else "аналогам"
def _price_from_inputs(
@@ -2646,45 +2680,82 @@ def _price_from_inputs(
n_analogs = 0
cv = None
- # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above).
- # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust
- # as a market median — suppress the AGGREGATE (median/range/n_analogs/cv)
- # exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/
- # insufficient_data chain below all take the already-honest zero-analogs
- # path automatically (no new branches there). `listings_clean` itself is
- # deliberately LEFT INTACT (not cleared) — the same-building anchor's own
- # ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell
- # "genuinely zero nearby listings" from "some nearby listings, just too few
- # to trust as THIS estimate's headline" — those are different confidence
- # signals and clearing the list here would conflate them. The caller
- # (estimate_quality) uses `listings_headline_thin_n` on the returned
- # PricingResult to also keep suppressed listings out of the display
- # `analogs` cards when no anchor overrides the headline (n_analogs
- # invariant: cards shown ⊆ what n_analogs counts).
+ # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above)
+ # — priority RESTORED 2026-08-10 (product correction on top of #oblast-F):
+ # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy
+ # enough to headline on their OWN — a more reliable source should win when
+ # one exists. Two sub-cases:
+ # (i) a usable ДКП deals corridor is available (same threshold the
+ # #oblast-D deals-headline-fallback block below itself requires,
+ # DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the
+ # listings AGGREGATE is suppressed to zero here so that block takes
+ # over the headline, EXACTLY like original #oblast-E. This is the
+ # Серов repro this gate exists for: n=3 listings must not outrank a
+ # 54-deal corridor. `listings_clean` stays intact (never cleared) —
+ # both for the anchor ghost-anchor guard (#1871) AND so
+ # estimate_quality() still surfaces these listings as display
+ # `analogs` cards even though they no longer drive n_analogs/median.
+ # (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the
+ # real thin median rather than refusing outright. By the time
+ # control reaches this function, estimate_quality() has already run
+ # the #oblast-F relaxation cascade (room-adjacency / freshness /
+ # novostroyki / radius) trying to grow the sample past the
+ # threshold — `listings` here is whatever that cascade could find.
+ # NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from
+ # the `deals_headline_due_to_thin_listings` PricingResult field set later —
+ # this one fires as soon as the gate DECIDES to cede (used below to skip
+ # the repair-coefficient/explanation blocks safely, regardless of whether
+ # anchor later overrides); the field fires only once the #oblast-D
+ # deals-headline-fallback block ACTUALLY builds the headline from deals
+ # (anchor may still override in between — see that block).
listings_headline_thin_n = 0
+ gate_ceded_to_deals = False
+ # Outward PricingResult field — set True below, ONLY inside the actual
+ # #oblast-D deals-headline-fallback block, once it fires for THIS reason.
+ deals_headline_due_to_thin_listings = False
if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N:
listings_headline_thin_n = n_analogs
- logger.info(
- "headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing "
- "listings-derived median (falling back to anchor/deals/insufficient_data)",
- n_analogs,
- HEADLINE_LISTINGS_MIN_N,
+ dkp_corridor_usable = (
+ dkp_raw is not None
+ and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
+ and dkp_raw.get("median_ppm2", 0) > 0
)
- median_ppm2 = 0.0
- q1_ppm2 = 0.0
- q3_ppm2 = 0.0
- median_price = 0
- range_low = 0
- range_high = 0
- n_analogs = 0
- cv = None
+ if dkp_corridor_usable:
+ gate_ceded_to_deals = True
+ logger.info(
+ "headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП "
+ "corridor (n=%s) available — suppressing listings-derived median, "
+ "ceding headline to deals/anchor chain",
+ n_analogs,
+ HEADLINE_LISTINGS_MIN_N,
+ dkp_raw.get("count", 0) if dkp_raw else None,
+ )
+ median_ppm2 = 0.0
+ q1_ppm2 = 0.0
+ q3_ppm2 = 0.0
+ median_price = 0
+ range_low = 0
+ range_high = 0
+ n_analogs = 0
+ cv = None
+ else:
+ logger.info(
+ "headline sufficiency note #oblast-E: n=%d < %d listings, no usable "
+ "ДКП corridor — keeping real median, flagged low-reliability "
+ "(#oblast-F: never suppressed to zero without a fallback source)",
+ n_analogs,
+ HEADLINE_LISTINGS_MIN_N,
+ )
- # 4b. Repair coefficient — skipped when the headline was thin-suppressed
- # above (median_price is already 0; applying a coefficient would leave it
- # 0 but still emit a misleading "adjusted for repair state" note).
+ # 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING
+ # thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is
+ # meaningful there. Skipped when the gate ceded the headline to deals
+ # (gate_ceded_to_deals — median_price is already 0 above; applying a
+ # coefficient would leave it 0 but still emit a misleading "adjusted for
+ # repair state" note, same reasoning original #oblast-E used).
repair_coef = _repair_coefficient(repair_state)
repair_note = ""
- if listings_clean and not listings_headline_thin_n and repair_coef != 1.0:
+ if listings_clean and not gate_ceded_to_deals and repair_coef != 1.0:
median_price = int(median_price * repair_coef)
range_low = int(range_low * repair_coef)
range_high = int(range_high * repair_coef)
@@ -2725,19 +2796,20 @@ def _price_from_inputs(
area_widened,
listings=listings_clean,
)
- # #oblast-E: honest override — _compute_confidence's generic "не найдено
- # аналогов" is FALSE here (we DID find listings_headline_thin_n of them,
- # just too few to trust). Stays the final explanation unless a later block
- # (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest
- # reasoning — both of those already check truthy `explanation` and either
- # replace it (anchor) or append a construction-method clause that reads
- # this same thin-count (deals-fallback), so no contradiction either way.
- if listings_headline_thin_n:
+ # #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
+ # the gate above (real thin median kept, no usable deals corridor to cede
+ # to). Case (i) (gate_ceded_to_deals) must NOT set this text — the
+ # deals-headline-fallback block below writes its OWN "built from
+ # Rosreestr deals" explanation; setting this first would leave a
+ # contradictory "Оценка построена по N аналогам" sentence stapled in front
+ # of it. Stays the final explanation unless a later block (anchor /
+ # #oblast-D deals-fallback) overwrites it with its OWN honest reasoning.
+ if listings_headline_thin_n and not gate_ceded_to_deals:
confidence = "low"
explanation = (
- f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., "
- f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по "
- "такой маленькой выборке слишком чувствительна к случайным лотам."
+ f"Оценка построена по {listings_headline_thin_n} "
+ f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, "
+ "точность снижена."
)
# Tier note — информируем пользователя о качестве house-match
@@ -3316,10 +3388,16 @@ def _price_from_inputs(
n_analogs = 0
confidence = "low"
cv = None
- # #oblast-E: differentiate "genuinely zero listings" (unchanged wording)
- # from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" —
- # the latter must NOT claim "рядом нет объявлений" (false, contradicts the
- # thin-sufficiency explanation already set above this block).
+ # #oblast-E (priority restored 2026-08-10): differentiate "genuinely
+ # zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N,
+ # ceded to the deals corridor" (gate above, case i) — the latter must
+ # NOT claim "рядом нет объявлений" (false — some WERE found, just not
+ # trusted as headline on their own). `deals_headline_due_to_thin_
+ # listings` (returned on PricingResult) tells estimate_quality() this
+ # was the thin-cession path specifically, so it can (a) append the
+ # "оценка по сделкам — мало объявлений рядом" relaxation label, (b)
+ # cap reliability at 'low' — a real deals corridor is a real signal,
+ # just not a listings-comp one.
no_listings_clause = (
f" Из {listings_headline_thin_n} найденных объявлений недостаточно для "
"надёжной медианы —"
@@ -3331,9 +3409,11 @@ def _price_from_inputs(
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
" точность ориентировочная."
)
+ if listings_headline_thin_n:
+ deals_headline_due_to_thin_listings = True
logger.info(
"deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline"
- " (listings=0 [thin_suppressed=%d], anchor=None)",
+ " (listings=0 [thin_ceded=%d], anchor=None)",
int(median_ppm2),
dkp_raw["count"],
listings_headline_thin_n,
@@ -3469,6 +3549,7 @@ def _price_from_inputs(
listings_clean=listings_clean,
cv=cv,
listings_headline_thin_n=listings_headline_thin_n,
+ deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings,
)
@@ -3730,6 +3811,9 @@ async def estimate_quality(
house_type=target_house_type,
total_floors=payload.total_floors,
)
+ # #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
+ # surfaced later as the "снят фильтр по году постройки" relaxation label.
+ cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0
area_widened = False
if len(listings) < 5:
@@ -3778,6 +3862,146 @@ async def estimate_quality(
analog_tier = analog_tier_wa
search_radius_m = fallback_radius_m
+ # ── #oblast-F: relaxation cascade (never-block estimate, product decision
+ # 2026-08-10) ──────────────────────────────────────────────────────────
+ # Product requirement: NEVER refuse an estimate outright. If the 4-tier
+ # cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N,
+ # keep loosening search criteria — least → most distorting — until either
+ # the sample clears the threshold or we run out of steps. Every step that
+ # ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced
+ # via AggregatedEstimate.relaxations + appended to confidence_explanation
+ # below) so a low-reliability estimate honestly explains why it stretched
+ # the search. Each step carries FORWARD the relaxations already applied by
+ # earlier steps (cumulative widening), not just its own single criterion.
+ relaxations: list[str] = []
+ if cohort_dropped:
+ relaxations.append("снят фильтр по году постройки")
+
+ cur_rooms_min: int | None = None
+ cur_rooms_max: int | None = None
+ cur_fresh_days = LISTINGS_FRESH_DAYS
+ cur_allow_novostroyki = False
+ cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE
+
+ async def _try_relax(
+ *,
+ rooms_min: int | None,
+ rooms_max: int | None,
+ fresh_days: int,
+ allow_novostroyki: bool,
+ radius_m: int,
+ area_tolerance: float,
+ ) -> tuple[list[dict[str, Any]], str] | None:
+ """Один шаг каскада #oblast-F. Возвращает (listings, tier) только если
+ кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана
+ (ничего реально не выиграла) и вызывающий её не применяет."""
+ candidate, _, tier = await asyncio.to_thread(
+ _fetch_analogs,
+ db,
+ lat=geo.lat,
+ lon=geo.lon,
+ rooms=payload.rooms,
+ rooms_min=rooms_min,
+ rooms_max=rooms_max,
+ area=payload.area_m2,
+ radius_m=radius_m,
+ area_tolerance=area_tolerance,
+ fresh_days=fresh_days,
+ allow_novostroyki=allow_novostroyki,
+ full_address=geo.full_address,
+ target_house_id=target_house_id,
+ year_built=target_year,
+ house_type=target_house_type,
+ total_floors=payload.total_floors,
+ )
+ if len(candidate) > len(listings):
+ return candidate, tier
+ return None
+
+ # (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для
+ # rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0
+ # тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA.
+ if len(listings) < HEADLINE_LISTINGS_MIN_N:
+ if payload.rooms <= 1:
+ try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии"
+ else:
+ try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA
+ try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA
+ rooms_label = "комнатность ±1"
+ rooms_result = await _try_relax(
+ rooms_min=try_rooms_min,
+ rooms_max=try_rooms_max,
+ fresh_days=cur_fresh_days,
+ allow_novostroyki=cur_allow_novostroyki,
+ radius_m=search_radius_m,
+ area_tolerance=cur_area_tolerance,
+ )
+ if rooms_result is not None:
+ listings, analog_tier = rooms_result
+ cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max
+ relaxations.append(rooms_label)
+
+ # (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60).
+ if len(listings) < HEADLINE_LISTINGS_MIN_N:
+ fresh_result = await _try_relax(
+ rooms_min=cur_rooms_min,
+ rooms_max=cur_rooms_max,
+ fresh_days=LISTINGS_FRESH_DAYS_RELAXED,
+ allow_novostroyki=cur_allow_novostroyki,
+ radius_m=search_radius_m,
+ area_tolerance=cur_area_tolerance,
+ )
+ if fresh_result is not None:
+ listings, analog_tier = fresh_result
+ cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED
+ relaxations.append("объявления за 60 дней")
+
+ # (c) снять guard listing_segment — допустить новостройки в comp-пул.
+ if len(listings) < HEADLINE_LISTINGS_MIN_N:
+ novo_result = await _try_relax(
+ rooms_min=cur_rooms_min,
+ rooms_max=cur_rooms_max,
+ fresh_days=cur_fresh_days,
+ allow_novostroyki=True,
+ radius_m=search_radius_m,
+ area_tolerance=cur_area_tolerance,
+ )
+ if novo_result is not None:
+ listings, analog_tier = novo_result
+ cur_allow_novostroyki = True
+ relaxations.append("учтены новостройки")
+
+ # (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M.
+ # Пропускается, когда пользователь явно зафиксировал radius_m — тот же
+ # контракт, что и у существующего radius-fallback выше (#2044: сервер не
+ # авто-расширяет поиск за пределы выбранного пользователем радиуса).
+ if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None:
+ for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M):
+ if relax_radius <= search_radius_m:
+ continue
+ radius_result = await _try_relax(
+ rooms_min=cur_rooms_min,
+ rooms_max=cur_rooms_max,
+ fresh_days=cur_fresh_days,
+ allow_novostroyki=cur_allow_novostroyki,
+ radius_m=relax_radius,
+ area_tolerance=cur_area_tolerance,
+ )
+ if radius_result is not None:
+ listings, analog_tier = radius_result
+ search_radius_m = relax_radius
+ fallback_used = True
+ if len(listings) >= HEADLINE_LISTINGS_MIN_N:
+ break
+
+ # Area/radius relaxations derived from FINAL state (covers both the
+ # pre-existing Tier B/C radius/area widening above AND step (d) here) —
+ # a single check avoids double-labelling the same underlying widening.
+ if area_widened:
+ relaxations.append("площадь ±25%")
+ if search_radius_m > base_radius_m:
+ relaxations.append(f"радиус расширен до {search_radius_m} м")
+
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
# corridor_high был доступен для Tier C-гейта и soft-клампа headline.
@@ -3990,7 +4214,41 @@ async def estimate_quality(
ratio_basis = pr.ratio_basis
listings_clean = pr.listings_clean
cv = pr.cv
- listings_headline_thin_n = pr.listings_headline_thin_n
+
+ # #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals
+ # corridor because the base listings sample was thin — a real signal (real
+ # Rosreestr deals), just not a listings-comp one. Recorded as its own
+ # relaxation label (distinct from the #oblast-F cascade labels above, which
+ # describe attempts to grow the LISTINGS sample — this describes switching
+ # sources entirely).
+ if pr.deals_headline_due_to_thin_listings:
+ relaxations.append("оценка по сделкам — мало объявлений рядом")
+
+ # #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/
+ # deals-fallback override above) — independent of `confidence` (older
+ # unique-address/IQR metric, see AggregatedEstimate docstring). If the
+ # #oblast-F cascade had to relax anything to get here, an otherwise-"ok"
+ # sample is downgraded to "low" — the raw count looks fine, but it only
+ # exists because we widened the search past the user's exact criteria.
+ if n_analogs >= 8:
+ reliability: Literal["ok", "low", "very_low"] = "ok"
+ elif n_analogs >= 3:
+ reliability = "low"
+ else:
+ reliability = "very_low"
+ if pr.deals_headline_due_to_thin_listings:
+ # #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero —
+ # the headline came from deals instead), which would otherwise bucket
+ # to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is
+ # a real, meaningful signal — "не выше low" (product spec), not
+ # "почти нет сигнала" (what 'very_low' would imply here).
+ reliability = "low"
+ elif relaxations and reliability == "ok":
+ reliability = "low"
+ if relaxations:
+ explanation = (explanation or "") + (
+ " Применены послабления подбора: " + ", ".join(relaxations) + "."
+ )
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
@@ -4026,14 +4284,6 @@ async def estimate_quality(
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
metadata_lots = display_pool
- elif listings_headline_thin_n:
- # #oblast-E: headline was suppressed (thin radius sample, no anchor to
- # take over) — do NOT surface those same listings as display cards
- # either, else `analogs` would show N cards while n_analogs==0 (broken
- # invariant, same dishonesty this gate exists to remove). Degrades to
- # the exact same empty-display state as "genuinely zero listings".
- analogs_lots = []
- metadata_lots = []
else:
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
@@ -4355,6 +4605,11 @@ async def estimate_quality(
cv=cv,
source_counts=source_counts,
created_at=now,
+ # #oblast-F (never-block relaxation cascade) — применённые ослабления
+ # подбора + производная надёжность выборки (см. reliability computation
+ # above, независимо от `confidence`).
+ relaxations=relaxations,
+ reliability=reliability,
)
@@ -4760,11 +5015,27 @@ def _extract_short_addr(full_address: str | None) -> str | None:
# Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе.
# Работает для FORWARD и REVERSE форматов Nominatim.
+# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после
+# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ
+# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт
+# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый
+# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе
+# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract
+# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе.
+# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` —
+# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest-
+# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+`
+# после этого не находит пробел (следующий символ — "и"), движок
+# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит
+# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают
+# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
+# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
+# покрывает оба варианта (с точкой и без).
_STREET_KW_RE = re.compile(
r"(? NOW() - (:fresh_days || ' days')::interval
@@ -4956,8 +5230,13 @@ _COMMON_WHERE = """
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
-- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает
-- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента —
- -- это вторичка или неклассифицированный объект).
- AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
+ -- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki
+ -- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) —
+ -- дефолт False сохраняет канон-guard byte-identical.
+ AND (
+ CAST(:allow_novostroyki AS boolean) IS TRUE
+ OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
+ )
-- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled,
-- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒
-- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает
@@ -5012,6 +5291,14 @@ def _fetch_analogs(
cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive
cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive
target_house_id: int | None = None, # #6: canonical house for same-building Tier S
+ # #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты
+ # byte-identical старому поведению (exact rooms match / 14 дней / без
+ # новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО
+ # когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring.
+ rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms
+ rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms
+ fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED
+ allow_novostroyki: bool = False, # #oblast-F (c)
) -> tuple[list[dict[str, Any]], bool, str]:
"""SELECT аналогов — трёхуровневый house-match (S → H → W).
@@ -5052,21 +5339,29 @@ def _fetch_analogs(
"""
area_min = area * (1 - area_tolerance)
area_max = area * (1 + area_tolerance)
+ # #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms),
+ # byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a
+ # widened range only past HEADLINE_LISTINGS_MIN_N thinness.
+ eff_rooms_min = rooms if rooms_min is None else rooms_min
+ eff_rooms_max = rooms if rooms_max is None else rooms_max
# #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL
# (безвредно без фильтра); статический фрагмент управляет только применением
# `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3
# bind-параметры не задействованы, инъекции нет.
dup_filter = "AND rn_dup = 1"
base_params: dict[str, Any] = {
- "rooms": rooms,
+ "rooms_min": eff_rooms_min,
+ "rooms_max": eff_rooms_max,
"area_min": area_min,
"area_max": area_max,
- "fresh_days": LISTINGS_FRESH_DAYS,
+ "fresh_days": fresh_days,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"cohort_year_min": cohort_year_min,
"cohort_year_max": cohort_year_max,
# #2012: is_apartments hard-filter — see _COMMON_WHERE comment above.
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled,
+ # #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above.
+ "allow_novostroyki": allow_novostroyki,
}
# ── Tier S (canonical): same building via house_id_fk ─────────────────────
@@ -5391,7 +5686,8 @@ def _fetch_analogs(
FROM listings
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
AND (geo_precision IS DISTINCT FROM 'city')
- AND rooms = :rooms
+ -- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же.
+ AND rooms BETWEEN :rooms_min AND :rooms_max
AND area_m2 BETWEEN :area_min AND :area_max
AND is_active = true
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
@@ -5408,7 +5704,11 @@ def _fetch_analogs(
)
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
-- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE).
- AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
+ -- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE.
+ AND (
+ CAST(:allow_novostroyki AS boolean) IS TRUE
+ OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
+ )
-- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий
-- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению).
AND (
@@ -5450,16 +5750,18 @@ def _fetch_analogs(
"lat": lat,
"lon": lon,
"radius": radius_m,
- "rooms": rooms,
+ "rooms_min": eff_rooms_min,
+ "rooms_max": eff_rooms_max,
"area_min": area_min,
"area_max": area_max,
- "fresh_days": LISTINGS_FRESH_DAYS,
+ "fresh_days": fresh_days,
"target_year": year_built,
"target_house_type": house_type,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"cohort_year_min": cohort_year_min, # NEW
"cohort_year_max": cohort_year_max, # NEW
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012
+ "allow_novostroyki": allow_novostroyki, # #oblast-F (c)
},
)
.mappings()
@@ -6570,4 +6872,7 @@ def _empty_estimate(
# Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна.
address_precision=None,
analog_tier=None, # нет данных при empty estimate
+ # #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся
+ # (geocode failed / no coords), а не просто "мало нашлось".
+ reliability="very_low",
)
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index 8282f83c..676d8f71 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -1243,11 +1243,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
return min(prices), max(prices)
+def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str:
+ """#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest
+ footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals
+ corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT
+ estimate.analogs is non-empty (the thin listings that triggered the cession
+ are still shown below as reference cards — never cleared, see estimator.py
+ #1871 ghost-anchor guard). Same tone/plain-sentence style as the web
+ LowConfidenceBanner for this scenario. Empty string (no-op) otherwise —
+ covers both "healthy sample" and "genuinely zero, nothing to show" cases."""
+ if estimate.n_analogs != 0 or not estimate.analogs:
+ return ""
+ return (
+ f'
'
+ "Оценка построена по зарегистрированным сделкам Росреестра — подходящих "
+ "объявлений поблизости почти нет. Объявления ниже приведены справочно, "
+ "для наглядности рынка.
"
+ )
+
+
+def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str:
+ """#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability`
+ (estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report
+ already shows this (LowConfidenceBanner); the PDF stayed silent, a
+ client-visible discrepancy between the two. Empty string (no-op) when
+ reliability=='ok' and relaxations is empty — the common, unrelaxed case,
+ byte-identical to the report before these fields existed."""
+ if estimate.reliability == "ok" and not estimate.relaxations:
+ return ""
+ if estimate.relaxations:
+ detail = "Подбор аналогов расширен: " + ", ".join(
+ _html.escape(r) for r in estimate.relaxations
+ )
+ else:
+ # relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка,
+ # которую каскад ослаблений не смог расширить, см. estimator.py
+ # #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика,
+ # что и в счётчике выше страницы).
+ detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)"
+ return f"""
+
+ Точность оценки снижена.
+ {detail} — данные ниже приведены с этой оговоркой.
+
+"""
+
+
# ── Page 2: Listings (market) ────────────────────────────────────────────────
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
- n_total = estimate.n_analogs
+ # #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw
+ # estimate.n_analogs is the count of listings that drove the HEADLINE math —
+ # it is deliberately 0 when the headline was ceded to the ДКП deals corridor
+ # (estimator.py `deals_headline_due_to_thin_listings`), even though the thin
+ # listings that triggered that cession are still shown below as display cards
+ # (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor
+ # guard comment). Printing raw n_analogs there read as "0 шт." above a
+ # non-empty examples table — a client-visible contradiction. n_analogs is
+ # normally >= len(analogs) (analogs is a top-10-capped SUBSET of what
+ # n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a
+ # no-op in that common case (count stays the honest FULL n_analogs) and only
+ # changes anything in this one pathological case, where it falls back to
+ # "how many are actually shown" instead of the dishonest zero.
+ n_total = max(estimate.n_analogs, len(estimate.analogs))
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
@@ -1306,6 +1366,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
examples_rows = _examples_rows(top5)
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
+ # #pdf-honesty — see helper docstrings above. Both no-op ("") in the common
+ # (unrelaxed, non-deals-sourced) case — byte-identical page in that case.
+ deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate)
+ reliability_note = _reliability_note_html(estimate, n_total)
return f"""
@@ -1320,6 +1384,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
diff --git a/tradein-mvp/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}`
: "Нет фото"}
- {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}шт
- Показано {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 053/124] =?UTF-8?q?feat(tradein):=20=D0=B2=D0=B5=D1=80?=
=?UTF-8?q?=D1=81=D0=B8=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=B0?=
=?UTF-8?q?=20=E2=80=94=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=B8?=
=?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA,=20=D0=BF=D0=BE?=
=?UTF-8?q?=D0=B4=D0=B2=D0=B0=D0=BB,=20PDF,=20/versions=20(#2824)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.forgejo/workflows/deploy-tradein.yml | 48 +++++++
tradein-mvp/CHANGELOG.md | 46 +++++++
tradein-mvp/VERSION | 1 +
tradein-mvp/backend/Dockerfile | 24 ++++
tradein-mvp/backend/app/api/v1/version.py | 20 +++
tradein-mvp/backend/app/core/rbac.py | 4 +
tradein-mvp/backend/app/core/version.py | 83 ++++++++++++
tradein-mvp/backend/app/main.py | 2 +
.../app/services/exporters/trade_in_pdf.py | 36 ++++--
tradein-mvp/backend/tests/test_version_api.py | 99 +++++++++++++++
tradein-mvp/frontend/Dockerfile | 30 +++++
tradein-mvp/frontend/src/app/v2/layout.tsx | 10 ++
.../frontend/src/app/versions/page.tsx | 120 ++++++++++++++++++
.../src/components/trade-in/VersionFooter.tsx | 80 ++++++++++++
.../src/components/trade-in/v2/Footer.tsx | 17 +--
.../src/components/trade-in/v2/TopNav.tsx | 89 +++++++++----
.../src/components/trade-in/v2/ui-config.ts | 8 +-
tradein-mvp/frontend/src/lib/buildInfo.ts | 37 ++++++
tradein-mvp/frontend/src/lib/changelog.ts | 106 ++++++++++++++++
tradein-mvp/frontend/src/types/version.ts | 34 +++++
20 files changed, 845 insertions(+), 49 deletions(-)
create mode 100644 tradein-mvp/CHANGELOG.md
create mode 100644 tradein-mvp/VERSION
create mode 100644 tradein-mvp/backend/app/api/v1/version.py
create mode 100644 tradein-mvp/backend/app/core/version.py
create mode 100644 tradein-mvp/backend/tests/test_version_api.py
create mode 100644 tradein-mvp/frontend/src/app/versions/page.tsx
create mode 100644 tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx
create mode 100644 tradein-mvp/frontend/src/lib/buildInfo.ts
create mode 100644 tradein-mvp/frontend/src/lib/changelog.ts
create mode 100644 tradein-mvp/frontend/src/types/version.ts
diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml
index ee062b18..d45dd831 100644
--- a/.forgejo/workflows/deploy-tradein.yml
+++ b/.forgejo/workflows/deploy-tradein.yml
@@ -30,11 +30,26 @@ jobs:
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
+ app_version: ${{ steps.build-meta.outputs.app_version }}
+ build_sha: ${{ steps.build-meta.outputs.build_sha }}
+ build_date: ${{ steps.build-meta.outputs.build_date }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
+ # Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
+ # правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
+ # проброшены как build-args в build-backend/build-frontend ниже (см.
+ # tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
+ # Считается ОДИН раз здесь, а не в каждой job отдельно.
+ - name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
+ id: build-meta
+ run: |
+ echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
+ echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
+ echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
+
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
# The file is written by the deploy job on every successful deploy.
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
@@ -107,8 +122,20 @@ jobs:
# scheduler_main импортирует пакет) — kit-only изменение обязано
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
- 'tradein-mvp/packages/scraper-kit/**'
+ # APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
+ # ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
+ # правок кода обязан пересобрать образ, иначе GET /version и колонтитул
+ # PDF продолжат отдавать старое значение при формально «успешном» деплое.
+ - 'tradein-mvp/VERSION'
frontend:
- 'tradein-mvp/frontend/**'
+ # NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
+ # причина, что у backend выше.
+ - 'tradein-mvp/VERSION'
+ # /versions статически запекает CHANGELOG.md в билд (см.
+ # frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
+ # frontend/** иначе не долетала бы до образа.
+ - 'tradein-mvp/CHANGELOG.md'
browser:
- 'tradein-mvp/browser/**'
infra:
@@ -211,6 +238,13 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
+ # APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
+ # backend/Dockerfile ARG→ENV) — читает app/core/version.py:
+ # GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
+ build-args: |
+ APP_VERSION=${{ needs.changes.outputs.app_version }}
+ BUILD_SHA=${{ needs.changes.outputs.build_sha }}
+ BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
@@ -236,6 +270,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+ # CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
+ # (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
+ # поэтому копируем внутрь ДО build. /versions статически запекает его
+ # содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
+ # комментарий). Не влияет на кэш другого шага — читается только этим.
+ - name: Stage CHANGELOG.md into frontend build context
+ run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
+
- name: Build & push tradein-frontend
uses: docker/build-push-action@v6
with:
@@ -246,9 +288,15 @@ jobs:
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
# уходит в notFound (404), не индексируется и не краулится.
+ # NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
+ # инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
+ # см. frontend/Dockerfile комментарий у соответствующих ARG).
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
+ NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
+ NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
+ NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
diff --git a/tradein-mvp/CHANGELOG.md b/tradein-mvp/CHANGELOG.md
new file mode 100644
index 00000000..0e70377d
--- /dev/null
+++ b/tradein-mvp/CHANGELOG.md
@@ -0,0 +1,46 @@
+# История версий «МЕРА»
+
+Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и
+[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно
+`## — ` (машинно читается страницей истории версий).
+
+## 2.1.0 — 2026-08-10
+
+Первая версия с явным версионированием. Номер продолжает ряд, который до этого
+показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние
+отчёты.
+
+### Добавлено
+
+- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и
+ реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень
+ уверенности в оценке.
+- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав
+ аналогов и сделок, формирование выкупной стоимости.
+- Аналитика по дому — история размещений объявлений и продаж в доме.
+- История прошлых оценок в личном кабинете, автодополнение адреса при поиске.
+- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история).
+- Чат поддержки на сайте, в том числе без входа в личный кабинет.
+- Публичный лендинг «МЕРА».
+- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта
+ страница истории версий.
+
+### Изменено
+
+- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более
+ раннего технического макета.
+
+### Исправлено
+
+- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе
+ комнатности не было варианта «Студия», из-за чего для студии подбирались
+ однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась.
+- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор
+ автоматически расширяется (студии, срок объявлений, новостройки, радиус),
+ а над результатом показывается предупреждение о сниженной точности и о том,
+ какие параметры пришлось расширить.
+- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части
+ адресов улица не распознавалась, и разделы оставались пустыми.
+- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов.
+- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
+ на обложке, некорректные допущения о сроке экспозиции.
diff --git a/tradein-mvp/VERSION b/tradein-mvp/VERSION
new file mode 100644
index 00000000..7ec1d6db
--- /dev/null
+++ b/tradein-mvp/VERSION
@@ -0,0 +1 @@
+2.1.0
diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile
index 4958161c..24d65162 100644
--- a/tradein-mvp/backend/Dockerfile
+++ b/tradein-mvp/backend/Dockerfile
@@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages
COPY --from=builder --chown=app:app /app/backend/app /app/app
COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts
+# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего
+# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION).
+# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста.
+COPY --chown=app:app VERSION VERSION
+
+# Версия продукта + короткий git SHA + дата сборки — запечены как build-args
+# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend).
+# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам
+# (VERSION-файл выше / "dev" / момент импорта модуля).
+#
+# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых
+# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на
+# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои
+# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале
+# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get +
+# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот
+# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже).
+ARG APP_VERSION=""
+ARG BUILD_SHA=""
+ARG BUILD_DATE=""
+ENV APP_VERSION=$APP_VERSION \
+ BUILD_SHA=$BUILD_SHA \
+ BUILD_DATE=$BUILD_DATE
+
USER app
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые
diff --git a/tradein-mvp/backend/app/api/v1/version.py b/tradein-mvp/backend/app/api/v1/version.py
new file mode 100644
index 00000000..86729727
--- /dev/null
+++ b/tradein-mvp/backend/app/api/v1/version.py
@@ -0,0 +1,20 @@
+"""GET /api/v1/trade-in/version — build metadata (product version + short SHA +
+build date), source `app/core/version.py`.
+
+Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) — это не
+секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только
+process env / уже загруженные при импорте константы (без похода в БД)."""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA
+
+router = APIRouter()
+
+
+@router.get("/version")
+def get_version() -> dict[str, str]:
+ """{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}."""
+ return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE}
diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py
index e8c5fc04..0dd654ff 100644
--- a/tradein-mvp/backend/app/core/rbac.py
+++ b/tradein-mvp/backend/app/core/rbac.py
@@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset(
"/api/v1/trade-in/support/anon/messages",
"/api/v1/trade-in/support/anon/unread",
"/api/v1/trade-in/support/anon/read",
+ # Версионирование (VERSION-файл + build-args, см. app/core/version.py):
+ # не секрет, читает только process env — быстрая справка для клиента/
+ # поддержки/смоук-теста, не должна требовать сессию.
+ "/api/v1/trade-in/version",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
diff --git a/tradein-mvp/backend/app/core/version.py b/tradein-mvp/backend/app/core/version.py
new file mode 100644
index 00000000..44101a3a
--- /dev/null
+++ b/tradein-mvp/backend/app/core/version.py
@@ -0,0 +1,83 @@
+"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`.
+
+`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env,
+запечённые в образ через build-args в `backend/Dockerfile`
+(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же
+ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и
+`date -u +%Y-%m-%dT%H:%M:%SZ`.
+
+Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env —
+тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов,
+см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта
+модуля. Ничего здесь не должно падать при отсутствии env (потребитель —
+и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`).
+
+Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда.
+Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION`
+в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и
+`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля /
+`@/lib/buildInfo` соответственно; не заводи третий.
+"""
+
+from __future__ import annotations
+
+import datetime as dt
+import os
+from pathlib import Path
+
+_DEFAULT_VERSION = "0.0.0"
+# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает
+# и локальный layout (backend/app/core/version.py → ../../../VERSION ==
+# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/
+# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION).
+_MAX_ANCESTORS = 6
+
+
+def _find_version_file() -> Path | None:
+ here = Path(__file__).resolve()
+ for ancestor in list(here.parents)[:_MAX_ANCESTORS]:
+ candidate = ancestor / "VERSION"
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def _read_version_file() -> str:
+ path = _find_version_file()
+ if path is None:
+ return _DEFAULT_VERSION
+ try:
+ text = path.read_text(encoding="utf-8").strip()
+ except OSError:
+ return _DEFAULT_VERSION
+ return text or _DEFAULT_VERSION
+
+
+def _default_build_date() -> str:
+ return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+# Читаются один раз при импорте модуля (совпадает с паттерном `settings =
+# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем,
+# перечитывать на каждый запрос незачем.
+APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file()
+BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev"
+BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date()
+
+
+def format_build_date_human(build_date: str = BUILD_DATE) -> str:
+ """ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF
+ колонтитул). Никогда не бросает исключение — при неразборчивой строке
+ возвращает её как есть (это футер отчёта, не API-контракт)."""
+ try:
+ parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ return build_date
+ return parsed.strftime("%d.%m.%Y")
+
+
+def product_version_line(product_name: str = "Мера") -> str:
+ """`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта
+ 2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF
+ колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`."""
+ return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}"
diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py
index 7e99bbf5..347cad8c 100644
--- a/tradein-mvp/backend/app/main.py
+++ b/tradein-mvp/backend/app/main.py
@@ -34,6 +34,7 @@ from app.api.v1 import (
support,
team,
trade_in,
+ version,
)
from app.core.auth_db import get_auth_engine
from app.core.config import settings
@@ -216,6 +217,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
+app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index 676d8f71..a89eadf6 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price
from matplotlib.patches import Rectangle
from app.core.config import settings
+from app.core.version import product_version_line
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
logger = logging.getLogger(__name__)
@@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом)
_BORDER = _LINE
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
-# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
-# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
-# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
-# HUD, а не версия PDF-модуля/API.
-_REPORT_ENGINE_VERSION = "v2.0.6"
-
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
@@ -505,13 +500,31 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
"ДАТА", report_date.strftime("%d.%m.%Y")
)
+ # Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение
+ # владельца продукта 2026-08-10, см. app/core/version.py::product_version_line.
+ # Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска
+ # реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера»,
+ # одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под
+ # существующим masthead-рядом — не растёт по высоте ни при каком контенте
+ # (клипается по ширине, не переносится), top-margin (25mm) даёт под неё
+ # запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты.
+ version_html = (
+ f'
"
)
@@ -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 (
+
+
+ )}
+
+ );
+}
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 (
-