Commit graph

541 commits

Author SHA1 Message Date
4b4ab8b34c fix(tradein/imv): счётчики прогона в total_seen/new_count + лог дрейфа ремонта (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
По ревью PR #2675.

1. counters прогона не заполняли выделенные колонки. _column_counts
   (scrape_runs.py) берёт total_seen из ключей total_seen|lots_fetched и
   new_count из new_count|lots_inserted — ни одного из них в дикте не было,
   поэтому все 39 прогонов этого source лежат в БД с total_seen=0. А mark_done
   по этой же колонке шлёт алерт «3 подряд done с нулевым результатом» (#2625):
   даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня
   выстрелил бы ложной тревогой про капчу. Добавлены total_seen=checked и
   new_count=saved. Трейд-офф назван в комментарии: на исчерпанной очереди
   checked=0 три дня подряд тоже даст алерт — но пустая очередь при ежедневном
   расписании это и правда сигнал.

2. _map_renovation_type молча схлопывал в 'cosmetic' любое незнакомое непустое
   значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но
   дрейф вокабуляра реален (70950 строк listings с пустым нормализованным
   ремонтом). Добавлен logger.debug на случай «непустое, но не в карте» —
   паритет с house_type_normalizer, который такой лог уже пишет.

3. Обоснование дефолта 'cosmetic' в докстринге заменено на более сильное по
   данным: это одновременно МОДА и МЕДИАННАЯ категория популяции
   (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
   needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка, а
   не просто «не край шкалы». Там же названа асимметрия: поштучный путь
   эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит —
   решение осознанное (иначе теряем ещё ~32% домов очереди), чтобы следующий
   читатель не принял это за недосмотр.

Refs #2674
2026-08-06 00:58:11 +05:00
0815319e1c fix(tradein/imv): домовая оценка перестаёт врать про ремонт и тип дома (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
Три дефекта в house_imv_backfill, найденные системным поиском (эпик #2674/#2673).

1. Тип ремонта был захардкожен литералом 'cosmetic' — все 2685 запросов ушли
   как «косметический ремонт», хотя мода repair_state по объявлениям тех же
   домов другая: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631
   (косметика лишь 36%). Теперь renovation_type берётся из mode(repair_state)
   в том же агрегате, что уже считает медианы комнат/площади/этажа, и проходит
   через существующий estimator._IMV_REPAIR_MAP (ленивый импорт — estimator
   тянет scraper_adapters, а тот импортирует этот модуль). Второго словаря не
   заводим. Неизвестный ремонт (498 домов из 2685) остаётся 'cosmetic': это
   середина порядковой шкалы required < cosmetic < euro < designer, а не край,
   системного сдвига в одну сторону не даёт.

2. Неизвестный тип дома молча становился 'panel' — и когда типа нет вовсе, и
   когда он есть, но не совпал со словарём. Панель почти самый дешёвый класс
   (медиана по нашим же 2685 оценкам: block 122.6k < panel 128.8k <
   brick 131.1k < monolithic 145.9k руб/м2), то есть дефолт систематически
   занижал. На проде так уехали 363 дома совсем без типа и 75 домов с
   camelCase-типом из Циана (56 из них monolithBrick — минус 11.7% против
   monolithic). Теперь сырое значение прогоняется через общий
   scraper_kit.house_type_normalizer.normalize_house_type (знает monolithBrick /
   gasSilicateBlock / aerocreteBlock / stalin и SCREAMING-вокабуляр Яндекса),
   дефолт 'panel' убран: тип не распознан → house_type=None → дом помечается
   no_params ('unknown house_type') и запрос к площадке не тратится. 'other' и
   'wireframe' намеренно НЕ маппятся — честного соответствия у них нет.

3. Прогон не умел падать: 31 прогон подряд с saved=0 и ~35 ошибками из 50
   помечен 'done'. Тот же класс, что #2670/#2657 — успех определялся как «не
   поймали известное исключение». Теперь saved=0 при errors>0 → mark_failed.
   Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) остаётся done.

Балкон/лоджия оставлены константами намеренно: покрытие listings.has_balcony
13.8%, listings.balcony_loggia 9.4%, и колонки противоречат друг другу (по
has_balcony «есть» у 62%, а по balcony_loggia самый частый случай — loggia
5650 против balcony 2794). Мода по одному-двум объявлениям на таком покрытии —
шум, а не данные.

Причина, по которой бэкфилл не сохранил НИ ОДНОЙ оценки за 34 дня, — вне этого
модуля и здесь не чинится (детали и числа в описании PR): 1240 домов легли на
отказе браузерного сайдкара «нет прокси» (гейт #2616, 05.07-02.08), а после
возврата прокси 05.08 — 23 на Page.evaluate «Execution context was destroyed»
в tradein-browser и 12 на 403 Авито.

Refs #2674
2026-08-06 00:29:49 +05:00
96d62e418b Merge pull request 'fix(tradein/scraper): пропуск расписания пишет строку прогона со статусом skipped (#2658)' (#2662) from fix/2658-loud-skip-status into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m6s
Deploy Trade-In / test (push) Successful in 2m42s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 2m11s
2026-08-05 18:24:04 +00:00
d173163025 fix(tradein): тест ловит копию константы, а не equality; честный комментарий про вклад свежести (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m44s
По ревью PR #2664.

1. test_freshness_window_is_the_estimator_constant_not_a_copy проверял
   `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` — CPython кэширует
   малые int, поэтому скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы
   ПРОШЁЛ, хотя докстринг обещает ловить ровно это. Прошлая фальсификация
   срабатывала лишь потому, что откат удалял имя целиком (AttributeError).
   Теперь проверяем исходник через inspect.getsource — фальсифицировано
   подстановкой копии литерала вместо импорта: тест краснеет.

2. Комментарий в location_index.py приписывал свежести чужую заслугу.
   Прод-разложение: из −14.8% сдвига городской медианы −14.7 п.п. даёт
   сегментный гард и лишь −0.18 п.п. свежесть. Для этой метрики свежесть —
   не коррекция смещения, а страховка на будущее, оплаченная третью пула
   (3 504 вторичных строки, из них 2 724 живые) и ростом дисперсии: на центре
   ЕКБ n 423 → 86, индекс гуляет по выбору окна на 12-14 п.п. Размен верный,
   но он должен быть написан как размен.

   Там же задокументирован новый режим отказа: свежесть связала витрину со
   здоровьем сбора — встанет скрейпинг на 14 дней, и insufficient_data
   прилетит всем пользователям разом. Учитывая, что #2574 это месяц молчаливой
   поломки сбора, сценарий не гипотетический.

Окно свежести не меняю — вопрос вынесен отдельно.

Refs #2660
2026-08-05 23:13:22 +05:00
b800760c24 fix(tradein/scraper): фильтр skipped в админке + освежение схлопнутой строки (#2658)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
Правки по ревью PR #2662.

Фильтр статуса. `GET /admin/scrape/runs?status=skipped` отдавал 422 — 'skipped' не
было в Literal, а во фронте не было чипа. Строки рисовались, но задать вопрос
«что сейчас пропускается» на единственной поверхности, построенной ровно для
этого, было нельзя. Добавлено в оба места (translateStatus «пропущено» и
нейтральный бейдж уже умели).

Схлопывание освежает строку. UPDATE двигал только finished_at/heartbeat_at, из-за
чего живой стрик замерзал: списки прогонов сортируют ORDER BY started_at DESC и
берут limit=20, поэтому 37-дневный пропуск утонул бы под свежими прогонами других
источников — след в базе есть, на экране нет. Теперь started_at = NOW(), а начало
стрика переезжает в counters.first_skip_at; сортировку общего списка не трогаем
(она про все источники, чинить надо было одну строку). Там же обновляется
counters.detail — иначе в строке 37 дней висел текст «протухли 1 день назад»,
хотя именно эта цифра и есть предмет issue. jsonb_set заменён на `||` +
jsonb_build_object: три вложенных jsonb_set читать в 3 ночи невозможно, а NULL в
jsonb_set обнуляет весь counters.

Поиск последней строки. `ORDER BY id DESC` не ложится на индекс
(source, started_at DESC) из миграции 015 — для unknown_source (тикает каждые
60 с бессрочно) это отбор всех строк источника с сортировкой раз в минуту.
Теперь ORDER BY started_at DESC, id DESC.

session_expires_at получил valid_only: предупреждение «скоро протухнут» считает
срок ИМЕННО той записи, которую взял load_session — при нескольких аккаунтах
свежайшая-любая может быть чужой протухшей строкой. Диагностика после None
по-прежнему смотрит на свежайшую любую (валидных там нет по определению).

Запись пропуска намеренно НЕ обёрнута в свой try/except: если db.execute падает,
то падает и claim следующего расписания в этом же тике — тик срывается в любом
случае, а глушить исключение здесь значило бы вернуть ровно тот немой пропуск,
ради которого заведён #2658. Самовосстановление через 60 с.
2026-08-05 23:10:47 +05:00
837ad8cfd4 fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m42s
Пользовательская половина разбора #2574: витрины читают listings без сегмента
и без свежести, поэтому показывают числа, посчитанные не по тому пулу.

1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings().
   27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и
   девелоперский прайс (который не торгуется) формировал показываемый процент
   торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция
   намеренно смотрит и снятые объявления, иначе к сделке нечего подставить.
   Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая
   перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205).

2. location_index — предикат свежести + сегментный гард в обоих запросах
   медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на
   тот же пул, на котором считается цена; окно свежести берётся импортом
   LISTINGS_FRESH_DAYS, второго определения константы не заводим.

3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем:
   рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе).
   Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть
   как «всё собирается».

Refs #2660
2026-08-05 22:39:48 +05:00
0b54b96984 fix(tradein/scraper): пропуск расписания пишет строку прогона со статусом skipped (#2658)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m41s
Пропуск наступившего окна был немым: logger + сдвиг next_run_at, ни строки в
scrape_runs, ни изменения last_run_at. cian_history_backfill так простоял 37 дней
на протухших куках Циана и снаружи выглядел работающим — next_run_at исправно
двигался вперёд, а docker-логи с warning'ом терялись на каждом редеплое.

Статус 'skipped' заведён ещё миграцией 015 и локализован во фронте («пропущено»),
но в проде имел 0 строк — механизм построен и ни разу не использован. Задействуем
его во всех пяти местах, где расписание пропускалось без следа: kit `_claim_run`
(already_running / concurrent_claim / running_appeared_under_lock), kit
`scheduler_loop` (unknown_source) и продуктовый cian `pre_claim`. Причина — слаг в
`error`, по нему «нет кук» отличается от «уже бежит» запросом, а не грепом логов.

Подряд идущие одинаковые пропуски схлопываются в одну строку со счётчиком
`counters.skips`: «уже бежит» и «неизвестный source» не двигают next_run_at и
иначе плодили бы строку каждый тик (60 с).

Алерт про куки жил в недостижимой ветке: он стоял там, где verify_session вернул
None, а на протухших куках load_session сам фильтрует expires_at_estimate > NOW()
и отдаёт None ещё в первой, немой ветке. Теперь алерт в обеих ветках и через
logger.error — в scraper-контейнере GlitchTip поднят с LoggingIntegration
(event_level=ERROR), поэтому прежний capture_message(level="warning") событием не
становился. Плюс предупреждение ЗАРАНЕЕ (COOKIE_EXPIRY_WARN_DAYS=5) в том же
pre_claim: обновление кук — ручная операция, алерт по факту протухания приходит,
когда сбор уже встал.

Монитор нулевых прогонов (#2625) не трогаем: обе alert-выборки отбирают
failed/banned/done/cancelled, поэтому 'skipped' в стрик не попадает и его не
прерывает — пропуск не «прогон вернул ноль лотов», смешивать нельзя.
2026-08-05 22:35:51 +05:00
17fcf746f7 Merge remote-tracking branch 'origin/main' into fix/2603-geocode-city-hint-tails
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m41s
# Conflicts:
#	tradein-mvp/backend/app/api/v1/admin.py
2026-08-05 20:25:28 +05:00
5ecd5361fd fix(tradein/geocode): гейт мусорного города вынести в общий хелпер и прошить в admin-путь (#2603)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m42s
Первый коммит починил только scripts/geocode_deals_nominatim.py — ручной скрипт.
Тот же дефект оставался на живом пути: POST /admin/geocode-missing?target=deals
отдавал сырой row["city"] в city_hint, а deals.city росреестровое и в хвосте
распределения содержит не-города («Бессонова», «Билейский рыбопитомник»). Любой
не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и уезжает префиксом в запрос
провайдеру, то есть мусорный хинт хуже отсутствия хинта.

Гейт вынесен в geocoder.known_city_hint (сверка с SVERDLOVSK_OBLAST_CITIES —
тем же набором, который уже питает _names_non_ekb_city / _ekb_local_tiers_allowed)
и переиспользуется всеми тремя потребителями city_hint: скриптом, admin-ручкой и
задачей geocode_missing. Копий функции нет — четвёртый потребитель, если появится,
получит гейт сам.

Тесты: мусорный город -> хинт не передаётся, валидный -> передаётся; проверено
фальсификацией (без фикса все три новых теста краснеют).
2026-08-05 20:20:41 +05:00
00bc07a55f fix(tradein/proxy): backup-узел должен быть пригоден + рычаг снятия бана (#2600 п.2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m45s
Правки по deep-review PR #2654.

MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.

MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).

LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.

LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.

LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.

Refs #2600
2026-08-05 17:45:07 +05:00
964867a943 feat(tradein/proxy): здоровье прокси по паре «узел × источник» (#2600 п.2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m43s
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.

Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.

Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.

Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.

Refs #2600
2026-08-05 17:14:12 +05:00
d362b16d7c fix(tradein/proxy): доводить сигнал бана площадки до пула (#2600 п.1) (#2653)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m40s
2026-08-05 11:37:35 +00:00
aa5bb76822 fix(tradein/proxy): отличать ручное выключение узла от авто-выключения (#2610) (#2652)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m35s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m17s
2026-08-05 10:18:02 +00:00
59c072fc4c chore(tradein): удалить мёртвые mobileproxy env-переменные и rotate-ip (#2616 шаги 2-3) (#2650)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 2m45s
Deploy Trade-In / build-browser (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m29s
2026-08-05 09:35:54 +00:00
90332e9827 fix(tradein): area-бакеты в asking→sold — и расчёт, и применение (#2620) (#2648)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 1m28s
2026-08-05 08:11:04 +00:00
2aa79e2143 fix(tradein): честный радиус поиска аналогов на карте v2 (#2632) (#2643)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m0s
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 1m23s
2026-08-05 06:13:58 +00:00
86ea09f2d5 fix(tradein/scraper): капча Cian и заглушки Yandex — banned, не done (#2625) (#2642)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m34s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m57s
2026-08-04 19:03:33 +00:00
ef2b07ee9c fix(tradein/proxy): один прокси на сессию браузера вместо смены на каждом запросе (#2640)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m26s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-08-04 18:45:37 +00:00
7d13e93792 fix(tradein/scraper): не ходить через мёртвый запасной прокси (#2634)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 38s
Deploy Trade-In / build-browser (push) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 2m44s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 2m27s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-08-04 18:27:49 +00:00
8b79e9659b fix(tradein/estimate): не строить цену по одному-двум аналогам, не отдавать 0 ₽ (#2629)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m15s
2026-08-02 12:41:48 +00:00
0abfe020df fix(tradein/geocoder): строгий матч литеры дома + починка DaData region-фильтра (#2622)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m26s
2026-08-02 12:06:44 +00:00
bot-backend
86afbf9e6f fix(tradein/estimate): не применять хедонику к неправдоподобному году постройки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
house_metadata (OSM/кадастр) отдаёт year_built без валидации; на проде
встретился 1829, который клампился в нижний пол хедонического фактора
(estimate_hedonic_factor_min=0.75) и резал выкупную цену на фикс. -25%
без физического смысла — модель никогда не видела осмысленного объёма
домов старше 1955 (см. COHORTS). Год вне [1917, текущий+3] теперь
трактуется как отсутствующий (None, нейтральный year-term) вместо
клампа, с WARNING-логом по house_id/адресу.

Единая точка входа _sanitize_build_year() в estimate_quality() перед
cohort-фильтром, house-match scoring и хедоническим фактором — покрывает
оба источника года (payload.year_built и house_meta.year_built).
2026-08-02 14:04:11 +03:00
bot-backend
b586b5ff68 fix(tradein/snapshot): починить зависающий запрос снапшотов и добавить бюджет времени (#2607)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m37s
Root cause: event-diff CTE джойнил "today" (снимок за CURRENT_DATE) с "prior"
(DISTINCT ON по всей listing_source_snapshots, ~2.6-2.8M строк) обычным JOIN.
Планировщик оценивал today в 1 строку (свежевставленные в той же транзакции
строки ANALYZE ещё не видел) → Nested Loop без Materialize пересчитывал
DISTINCT ON по всей таблице заново на каждую из ~80-140k реальных строк today
(EXPLAIN на проде: cost≈300k на этом шаге) — прогон не укладывался ни в 6h
zombie-порог, ни в сутки, каждую ночь минимум с 19 июля.

Переписано на JOIN LATERAL (per-row indexed point-lookup через idx_lss_source_date,
cost упал до ~4.4/строку). Плюс budget_sec → SET LOCAL statement_timeout как
defense-in-depth (по образцу geocode_missing_listings) — задача теперь честно
падает в mark_failed вместо того чтобы висеть сутками, если план когда-нибудь
разрегрессирует снова.

Зомби-детектор (reap_zombies) не тронут — он только помечает scrape_runs.status,
не убивает backend (нет pid/application_name в схеме run'а); pg_terminate_backend
для этого — отдельный follow-up, не в этом PR.
2026-08-02 11:55:43 +03:00
887df54908 Merge pull request 'feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600)' (#2611) from feat/tradein-asocks-rotation into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m2s
2026-08-01 19:23:51 +00:00
bot-backend
bed2b7bca9 fix(tradein/proxy): pin ASocks rotate_url host — не слать токен на чужой прокси (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy
changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки
хоста Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы на чужой провайдер —
security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only,
хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый
запрос без Authorization (смысл ручной ротации — конкретный провайдер).

Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить
ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry
сообщений (не только reason/note).
2026-08-01 22:12:52 +03:00
bot-backend
b1563b86cb feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
2026-08-01 21:41:12 +03:00
bot-backend
876b666424 fix(tradein/proxy): не отдавать в fallback последний узел выделенной affinity (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m44s
Ревью PR #2609: domclick — ровно один узел (прод scrape_proxies.id=1),
намеренно вырезанный из общего пула через provider_affinity='domclick'
(см. 173_scrape_proxies_add_domclick_affinity.sql) — QRATOR банит всё,
кроме этого одного чистого residential-адреса. Fallback-запрос из
предыдущего коммита мог законно забрать его под avito/cian/yandex,
оставив domclick (сейчас исправно собирает: 6501 активных объявлений,
368/сутки) без прокси вообще — чинили бы один источник ценой полной
поломки другого.

- acquire(): fallback-SELECT дополнен условием "affinity='any' ИЛИ есть
  ДРУГОЙ enabled-узел той же affinity" через коррелированный EXISTS-
  подзапрос (WHERE + FOR UPDATE SKIP LOCKED + ORDER BY last_ok_at NULLS
  LAST, id — сохранены). Кандидат с единственным enabled-узлом своей
  выделенной affinity в fallback не участвует.
- Тесты: единственный domclick-узел → acquire('avito') возвращает None;
  второй enabled domclick-узел появляется — fallback снова срабатывает.
- Починен мок FakeSession (tests/services/test_proxy_pool.py):
  ветка "mark_health ok" раньше ставила enabled=True безусловно по
  совпадению общей подстроки "SET consecutive_fails = 0" (одинаковой в
  старом и новом SQL) — test_mark_health_ok_revives_disabled_proxy
  проходил бы и против кода без реанимации. Теперь ставит enabled=True
  только если в тексте SQL реально есть "enabled". Та же проблема была
  и в fallback-ветке (protects_last_node переопределял логику в Python
  независимо от SQL) — исправлено аналогично: применяется, только если
  в SQL реально есть EXISTS-подзапрос.
2026-08-01 21:36:02 +03:00
bot-backend
ad753c6a87 fix(tradein/proxy): самовосстановление пула и запасной прокси чужой affinity (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m42s
Прод-замер: disabled-узлы никогда не перепроверялись (WHERE enabled в
run_proxy_healthcheck) — auto-disable по DISABLE_THRESHOLD необратим,
транзиентный сбой = вечный приговор (id 11 сгорел за ночь, будучи
физически исправным). acquire() при пустой выборке по provider_affinity
падал в None, морив источник голодом при живых свободных узлах чужой
affinity.

- run_proxy_healthcheck: disabled-узлы проверяются реже (DISABLED_RECHECK_MINUTES=60
  либо last_check_at IS NULL); успешная проба реанимирует узел
  (enabled=true через mark_health) и инкрементит новый счётчик revived.
- mark_health(ok=True) теперь безусловно ставит enabled=true (реанимация).
- acquire: вторым заходом при пустой выборке своей affinity берёт любой
  свободный здоровый узел любой affinity (WARNING-лог), приоритет своих
  сохранён.
- _probe_proxy классифицирует неуспех (timeout/connect_error/http_error/other)
  в fail_kind — прокидывается в mark_health только для логирования; полноценное
  разделение порогов транзиент/бан отложено (см. docstring mark_health).
2026-08-01 08:22:43 +03:00
eccb895db1 feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод,
tradein_users/tradein_sessions, соединение с БД auth не открывается вообще.
Переключение делается одной переменной окружения ПОСЛЕ того, как на проде
появится пароль auth_app и будут скопированы данные. Так сделано намеренно:
мерж, который зависит от невыполненного ручного шага, — это мерж, который
ломает прод в момент невнимательности.

Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД
и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря
по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ
engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы
старт без DSN).

Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев
is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной
функции to_access_state(): True→active, False→disabled, а неизвестная строка,
NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если
следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет
пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой.

Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию —
иначе появляется timing-oracle и перечисление логинов. Верный пароль +
trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся.
Верный пароль + disabled → тот же generic 401, что и при неверном пароле.
Резолв уже выданной сессии пропускает только active — блокировка обрывает
сессию немедленно, а не по истечении sliding-refresh.

Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка
конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а
rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в
legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо
реестра, включая аккаунты с disabled.

Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту:
текст бэк вправе менять, код — нет.

Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и
не имеет DELETE на users (миграция 004, column-level).

Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit —
предсуществующий: проверен контрольным полным прогоном на чистом main
(2937 passed, тот же красный).
2026-08-01 02:50:14 +03:00
bot-backend
3d075632a9 chore(tradein/geocoder): удалить Яндекс-геокодер (#2593)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m28s
Убирает ядро Yandex Geocoder (forward/reverse/suggest lookups + region-check
+ bias-хелперы + EKB_BBOX dict) из app/services/geocoder.py — Yandex demo-key
исчерпан, Nominatim/DaData/локальные ЕКБ-тиры (geoportal/cadastral) остаются
единственными живыми провайдерами. Цепочка тиров после удаления: кэш →
геопортал ЕКБ → кадастр (house-match) → кадастр (raw) → Nominatim; в
подсказках дополнительно DaData.

НЕ затронуто (намеренно): Yandex.Недвижимость как источник объявлений
(source='yandex', yandex_city_sweep*, providers/yandex/serp.py:geocoderAddress),
Avito geocoder (providers/avito/imv.py:_geocode), EKB_BBOX_TIGHT/WIDE,
_nominatim_region_ok, scripts/*_yandex_reverse.py и их тесты, tests/fixtures/
yandex_geocode_sample.json (всё ещё используется test_audit_address_mismatch.py).

_SNAP_PRECISIONS оставлен с "exact" (недостижимо без Yandex-tier, но дёшево
хранить — parity с frontend MapPicker.tsx SNAP_PRECISIONS и не ломает
test_snap_precision_useful_exact_and_number).
2026-07-31 21:22:09 +03:00
fdf222cc5c Merge pull request 'fix(tradein/geocoder): fail-closed выбор ЕКБ-реестров вместо списка городов (#2582)' (#2589) from fix/tradein-geocoder-fail-closed into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m18s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m13s
2026-07-31 16:30:38 +00:00
bot-backend
af609c18f3 fix(tradein/geocoder): починить маркер села + fail-closed для явного city_hint (#2582)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m20s
H1 (deep-review PR #2589): `с\.` в _LOCALITY_MARKER_RE был мертворождён —
хвостовой \b не может сработать между "." и пробелом (оба не-словесные),
формат «с. <село>» (104/77к прод-адресов) не ловился НИ РАЗУ, зато ложно
срабатывал на инициалах «им с.п.королева». Живой инцидент воспроизведён:
"с. Новоалексеевское, ул. 8 Марта,37" снапалось в ЕКБ (совпадение до 5-го
знака, село в 35 км западнее). Заменил на lookahead-форму для однобуквенных
маркеров (с/д/дер/пгт/ст): "\.\s*(?=[а-яё])" — различает "д. 5" (дом-номер,
не матчит) от "д. Кашино" (деревня, матчит), что заодно позволило безопасно
включить "д."/"дер." (раньше исключённые из-за коллизии с house-маркером).

M2: гейт по city_hint оставался list-based (_names_non_ekb_city(city_hint)) —
для СТРУКТУРНОГО хинта (не свободного текста) fail-closed форма должна быть
обратной: "не Екатеринбург → закрыто", а не "есть в списке 37 → закрыто".
city_hint принимает произвольную строку без валидации (API напрямую, минуя
6-городный UI-selector) — city_hint="Реж" держал гейт открытым, geoportal
отдавал точный ЕКБ-дом, city_ambiguous=False делало это уверенно-неверным.

M3: добавлены изолирующие тесты — предыдущая параметризация "незнакомый
топоним" всегда несла ещё и "обл."/"республика"/"край" одновременно с
проверяемым сигналом, поэтому мёртвый "с\." прошёл незамеченным. Плюс честно
зафиксирована известная дыра (гейт по city_hint) — "реж, ленина 5" без
маркера остаётся в ЕКБ-дефолте (архитектурное решение отдельно, не список
городов).

docstring `_names_unrecognized_locality`/`_ekb_local_tiers_allowed` приведён
в соответствие с фактическим поведением regex.
2026-07-31 19:17:47 +03:00
bot-backend
174680a82e Merge forgejo/main into fix/tradein-geocoder-fail-closed
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m35s
Разрешён конфликт в geocoder.py между #2580 (city_hint threading в
локальный гейт + dual-query Tier 4 Nominatim) и #2582 (fail-closed
структурный маркер топонима вместо списка городов): `_ekb_local_tiers_allowed`
теперь принимает `city_hint` и закрывает гейт по OR трёх независимых
сигналов — известный не-ЕКБ город в тексте (#11), known-city в city_hint
(#2580 C1), незнакомый топоним-маркер в тексте (#2582). Добавлены тесты на
все 4 достижимые ветки (hint=известный город / маркер в тексте / ничего /
hint=Екатеринбург); полный набор тестов (свои + пришедшие из #2580) зелёный
одновременно.
2026-07-31 18:46:14 +03:00
790f69ee01 Merge pull request 'fix(tradein/rbac): скрыть «Доля в продаже» и «Кэш» от клиентских аккаунтов' (#2587) from fix/tradein-hide-saleshare-cache-from-clients into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 24s
Deploy / build-backend (push) Successful in 52s
Deploy / build-worker (push) Successful in 52s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / deploy (push) Successful in 2m4s
Deploy Trade-In / test (push) Successful in 3m7s
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-07-31 15:39:56 +00:00
bot-backend
61b329e724 fix(tradein/geocoder): fail-closed выбор ЕКБ-реестров вместо списка городов (#2582)
EKB-only локальные тиры (geoportal/cad_buildings) держались на списке из 37
городов области: "нет в списке -> считаем Екатеринбургом". Молча ломало любой
адрес вне списка, включая ДРУГИЕ РЕГИОНЫ РФ целиком -- подтверждено живыми
случаями в geocode_cache (Ялта/Крым, Трёхгорный/Челябинская обл., пос. Лёвиха,
Димитровград/Ульяновская обл. -- все получили координаты ЕКБ).

Новый гейт `_ekb_local_tiers_allowed`: локальные тиры разрешены только если
Екатеринбург подтверждён явно, ЛИБО в адресе вообще не назван город/регион
(bare "Ленина 1" -- основной трафик формы оценки, дефолт ЕКБ оправдан и
сохранён без изменений). Если назван ЛЮБОЙ другой топоним -- известный город
списка (`_names_non_ekb_city`, без изменений) или незнакомый
(`_names_unrecognized_locality` -- новый структурный маркер "обл./край/
республика/г./пос./с.", БЕЗ "д." -- коллизия с house-маркером "д. 5") -- гейт
закрыт, идём во внешние oblast/nation-aware провайдеры.

city_hint-логика #2580 не тронута (тот же файл, сосед по цели).
2026-07-31 18:36:12 +03:00
8d3b571925 Merge pull request 'fix(tradein/geocoder): не подставлять Екатеринбург молча — явный city_hint (#2576)' (#2580) from fix/tradein-geocoder-city-default into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 27s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m32s
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-07-31 15:23:32 +00:00
bot-backend
abb9398f3f fix(tradein/rbac): скрыть «Доля в продаже» и «Кэш» от клиентских аккаунтов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m19s
CI Trade-In / backend-tests (pull_request) Successful in 3m13s
CI / openapi-codegen-check (pull_request) Successful in 3m34s
CI / backend-tests (pull_request) Successful in 16m7s
Аккаунт praktika (DB-роль manager) видел оба пункта в топбаре на /trade-in/team.
Это внутренние инструменты — аналитика рынка и состояние кэшей/скраперов, —
клиентские аккаунты их видеть не должны (решение владельца продукта).

Гейт один — deny-список роли, потому что все три места сверяются с ним через
общий матчер: пункт меню (Topbar по scopePath из /me), страница (RouteGuard) и
серверные ручки (rbac_guard). Правка только фронта спрятала бы пункт, оставив
прямой URL и API открытыми.

Закрыто для employee/manager (DB_ROLE_PATHS) и для legacy pilot (roles.yaml):
  /trade-in/sale-share/**
  /trade-in/cache/**
  /trade-in/api/v1/buildings/**
  /trade-in/api/v1/trade-in/cache-stats/**

У cache-stats ГЛОБ, а не точный путь: точный паттерн — строгое равенство, его
обходит трейлинг-слэш ('…/cache-stats/' → allowed=True), и защита держалась бы
на Starlette redirect_slashes, а не на RBAC. Замерено после правки: все варианты
(слэш, %2f, ./, ../) дают 403, утечек нет.

Основной продукт не задет: buildings.py обслуживает ТОЛЬКО sale-share, секция
«Продажи в доме» на экране оценки питается estimate-хендлерами. admin и analyst
сознательно вне deny — запиннено тестом, иначе «синхронизация» списков закрыла
бы их молча.

Заодно починен КРАСНЫЙ pre-existing тест главного бэкенда:
backend/tests/test_rbac.py::test_get_role_known_users ждал pilot у всех
user1..user10, но user2 («Брусника») стал expired 2026-07-30. CI это пропустил —
auth/roles.yaml не входит в paths-filter backend/**, из-за чего сьют не бежал.

Тесты: 153 passed (tradein) + 24 passed (site-finder, было 23+1 failed).
Новые — e2e через реальный rbac_guard по session-ветке (именно ею ходит
praktika), пин deny_paths в выдаче /me, границы глоба и regression-guard'ы.
Проверены снятием deny: 7 тестов краснеют, т.е. не тавтологии.
2026-07-31 18:20:03 +03:00
bot-backend
b3c76c8c62 fix(tradein/geocoder): city_hint должен доходить до локальных тиров + не терять ЕКБ-привязку в Tier 4 (#2576)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m19s
Deep-review PR #2580 нашёл два блокера в предыдущем фиксе (#2576):

C1 — city_hint не участвовал в гейте локальных ЕКБ-only тиров
(geoportal/cadastral, `use_local_ekb` в geocode() и Tier 1 в suggest()).
Явный city_hint="Нижний Тагил" для "Ленина 1" всё равно попадал на ЕКБ-only
базы, получал "точный" ЕКБ-хит и при этом city_ambiguous=False (хинт ведь
был) — то есть система теперь ложно-уверенно утверждала неверный город.
Фикс: city_hint участвует в той же проверке _names_non_ekb_city (гейт/
gazetteer #2582 не трогаю — только добавляю вход).

C2 — снятие суффикса ", Екатеринбург" в _nominatim_suggest для случая
"город неизвестен" регрессило часть реальных ЕКБ-адресов: без текстовой
подсказки о городе Nominatim иногда предпочитает street-level матч в
соседнем городе-спутнике (эмпирика ревьюера: "Победы 20" без суффикса →
Верхняя Пышма вместо ЖК "Парк Победы" в Екатеринбурге). Решение — dual-query:
bare (честный, без города) И ЕКБ-suffixed запросы объединяются (не заменяют
друг друга), оба честных кандидата остаются в подсказках, пользователь
выбирает сам. Extra round-trip только для последнего fallback-тира
(cadastral/DaData/Yandex уже не сработали) — не задевает основной трафик.

Заодно (🟠, дешёвая правка): city_hint прокинут в _nominatim_lookup — с тех
пор как Yandex-ключ недействителен (#2585), это единственный живой внешний
провайдер, и его tie-break (предпочитает tight-ЕКБ bbox) без города не
различает одноимённые улицы внутри региона.

4 новых теста (C1×2, C2×2 + dedupe) — проверено что все 4 падают на коде до
этого коммита через git stash (только geocoder.py, тесты оставлены).
2026-07-31 17:55:18 +03:00
bot-backend
23b8816863 fix(tradein/estimator): гео-ограничение якорного тира — не брать чужие города (#2581)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m13s
Tier A (_fetch_anchor_comps, "тот же дом") матчил по normalized street+house
(_normalize_building_key намеренно дропает город) БЕЗ единого гео-предиката —
единственный запрос к listings в файле без ST_DWithin/города/house_id_fk/LIMIT.
"Серов, ул. Ленина, 5" получал якорь по ЕКБ-объявлениям (~40 191 из ~40 200
активных листингов — ЕКБ) с самым доверенным тиром 'A', завышая цену в 4-5х;
хуже — anchor_tier='A' блокировал честный #oblast-D deals-headline-fallback
(гейт `anchor_tier is None`), так что область не могла получить даже
резервную ДКП-оценку.

Фикс мирроит уже одобренный geo-bound для Tier S (f9ae6f0c, #oblast-D):
ST_DWithin(geom::geography, subject_point, ANCHOR_TIER_A_RADIUS_M) от
lat/lon субъекта, radius = DEFAULT_RADIUS_M (1000м, тот же константа что и
Tier S) — строковый match уже устанавливает "та же улица+дом", радиус нужен
только чтобы отсечь РЕАЛЬНО кросс-городские коллизии (ЕКБ/Серов — сотни км),
не для внутридомовой точности. Tier A целиком гейтится на lat/lon субъекта
(как Tier C) — без них геопредикат невозможен; в проде geo всегда есть
(_empty_estimate возвращается раньше при неудачном geocode).

Объявления без geom: `AND geom IS NOT NULL` + ST_DWithin (NULL → exclude,
не blind-include) — >99.97% листингов имеют координаты (см. f9ae6f0c),
исключение погрешности не создаёт.

_normalize_building_key НЕ тронут (город по-прежнему не входит в ключ) —
добавление городского токена сломало бы матчинг источников без города в
адресе (Avito anonymous) и внесло бы новый normalization-риск; кросс-
городская коллизия закрыта на SQL-уровне надёжнее.

Поправлен ложный комментарий _band_haircut (:1900-1908): "same-building
anchor pool для oblast не формируется" был неверен даже ДО фикса (Tier A
не имел гео-фильтра вовсе — либо матчил ЕКБ, либо честно матчил местные
листинги, если они были).

Тест-страж test_estimator_deals_headline_fallback_oblast_d.py использовал
голый db=MagicMock() для _fetch_anchor_comps — db.execute(...).mappings().all()
на unconfigured MagicMock тривиально возвращает [] (MagicMock __iter__ default),
так что anchor_tier никогда не мог стать 'A' и регрессия было бы невидима.
Пропатчено явно (документирует допущение) + добавлен новый тест, который
НЕ мокает _fetch_anchor_comps — гоняет реальную SQL-логику через фейковый
db.execute, симулирующий настоящую ST_DWithin-фильтрацию по haversine-
дистанции. На pre-fix коде (git stash) даёт headline=186 400 ₽/м² вместо
честных 30 000 (deal corridor) — воспроизводит репортнутый баг buквально.
EKB control-тест подтверждает: несколько объявлений в одном доме ЕКБ
по-прежнему формируют Tier A anchor (n=4, ~145k ₽/м²) — не деградировало.

test_tier_a_primary_1774.py: позитивные Tier A тесты теперь передают
lat/lon субъекта (гейт требует их) — тесты на python-side novostroyki-
гейтинг (ожидающие tier=None) оставлены с lat=None (конечный результат
не меняется: Tier A целиком пропускается без lat/lon, как и Tier C раньше).

Full suite: 2842 passed, 9 skipped, 1 pre-existing failure (test_search_cache_hit,
не связан). Ruff чист.
2026-07-31 17:39:00 +03:00
bot-backend
4b1a282234 fix(tradein/geocoder): не подставлять Екатеринбург молча — явный city_hint (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m17s
Раньше _yandex_lookup/_yandex_suggest/_nominatim_suggest молча подставляли
"Екатеринбург, " в запрос, если в адресе не было маркера города/области.
Житель Нижнего Тагила, вводя «Ленина, 1», получал уверенно неверную цену по
екатеринбургской улице Ленина (обе улицы называются одинаково) — фронт город
вообще не передаёт.

- geocode()/suggest() принимают опциональный city_hint: str | None; без него
  внешние тиры больше НЕ подставляют город, а bias (ll/spn) смещается на всю
  область (OBLAST66_VIEWBOX) вместо ЕКБ-центра. Явный маркер города в адресе
  или city_hint сохраняют прежнее поведение (ЕКБ-путь не деградирует).
- GeocodeResult.city_ambiguous — честный флаг «город определил провайдер, а
  не пользователь» (не эвристика на корректность), проброшен в
  AggregatedEstimate.target_city_ambiguous (ephemeral, не персистится).
- Cache-ключ geocode_cache учитывает city_hint (address|city=...) — без hint'а
  формат не меняется (backward-compat), с hint'ом разные города для одного
  текста адреса больше не делят одну запись.
- API: /api/v1/geocode/lookup, /suggest и POST /trade-in/estimate получили
  опциональный city_hint — контракт не ломается (default None).

23 новых теста в test_geocoder_city_hint.py; проверено что они падают
(ImportError на _cache_key) на коде до фикса через git stash.
2026-07-31 16:42:29 +03:00
bot-backend
f44ed4043c feat(tradein/db): авто-refresh ценовых бэндов по городам + порог для малых городов (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m21s
Миграция 178 засеяла deal_city_price_bands разово (N>=30 сделок), без scheduler'а
на refresh. Город без строки падал на глобальный DEAL_MIN_PPM2=50000 (ЕКБ-калибровка)
— для дешёвых городов области это не anti-outlier guard, а cut-off легитимного рынка
(Североуральск median ~21.7k). Замер по прод-данным: 289 из 369 не-ЕКБ городов
(1265 сделок) не имели строки и падали на ЕКБ-порог.

- 194_deal_city_price_bands_tiers.sql — трёхуровневая схема (tier колонка):
  full (N>=30, own p1/p99, unchanged) / rough (N 10-29, own p1 floor + фикс.
  ceiling 800000) / region_fallback (N 1-9, pooled областной p1=15263 вместо
  ЕКБ-порога). Екатеринбург по-прежнему не в таблице — estimator fallback
  byte-identical.
- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql — scrape_schedules
  row, окно 07:00-08:00 UTC (после rosreestr_dkp_import + asking_to_sold_ratio_refresh).
- app/tasks/deal_city_price_bands_refresh.py — периодический re-derive (kit-scheduler,
  byte-identical 194 derivation), без DELETE (множество городов монотонно растёт).
- app/services/product_handlers.py — регистрация Handler для нового source.

Валидация: scratch-БД (syntax_check) в прод-контейнере, synthetic данные на
границах тиров (N=9/10/29/30) + Екатеринбург/non-rosreestr/NULL exclusion, оба
файла применены дважды (идемпотентность подтверждена), scratch-БД удалена.
2026-07-31 16:33:20 +03:00
bot-backend
0835266516 feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m19s
Foundation для эпика #2549: session-cookie auth поверх legacy Caddy
trusted-header. app.services.auth_session — CRUD для tradein_sessions
(create/get/revoke) + get_user_by_username для password-логина; opaque
secrets.token_urlsafe токены, sliding last_seen_at/expires_at refresh
(не чаще раза в 5 минут).

POST /api/v1/auth/login проверяет password_hash (bcrypt) через
app.core.password, ставит httponly+secure cookie, пишет
login_success/login_failed в user_events; per-username+IP rate-limit
(SlidingWindowLimiter) отдельно от общего RateLimitMiddleware. POST
/logout ревокает сессию и чистит cookie. Оба пути exempt из rbac_guard's
auth-required gate (иначе логин сам себя не пропустил бы).

rbac_guard теперь dual-mode: session-cookie резолвится первым (DB-роль
employee/manager/admin -> paths как у pilot/+team/admin), fallback на
legacy X-Authenticated-User + roles.yaml БЕЗ ИЗМЕНЕНИЙ когда auth_mode
== "dual"; auth_mode == "db_only" отключает legacy header полностью.
Резолвленный сессией username инжектится в ASGI scope headers (до
call_next) — RequestAuditMiddleware и downstream route-хендлеры видят
его прозрачно; RateLimitMiddleware (внешний относительно rbac_guard)
для session-запросов лимитирует по IP, не по username — документированный
trade-off, не регрессия.

GET /me — session-first: валидная cookie отдаёт scope из tradein_users
без похода в roles.yaml; без cookie — прежний legacy путь. session_secret
остаётся опциональным (opaque-токены не требуют подписи) — пустое
значение только logger.warning на старте, не startup-fail.

Полный набор тестов (tests/test_rbac.py, test_internal_auth_secret.py,
test_account_quota.py) проходит без правок — regression-safe.
2026-07-30 10:48:28 +03:00
5f3579b8c2 feat(tradein/auth): восстановить доступ praktika + безлимит через данные вместо хардкода (#2544)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m2s
Deploy Trade-In / build-backend (push) Successful in 5m35s
Deploy Trade-In / deploy (push) Successful in 1m14s
2026-07-26 23:29:53 +00:00
036ff84eaa fix(tradein/tgbot): ограничение частоты на отправителя в мосте поддержки (#2543)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 5m1s
Deploy Trade-In / build-backend (push) Successful in 5m6s
Deploy Trade-In / deploy (push) Successful in 1m2s
2026-07-26 23:16:57 +00:00
35f5c3426b fix(tradein/scrapers): детекция дрейфа разметки вместо тихой пустоты (#2535)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m50s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m34s
2026-07-26 22:33:54 +00:00
dce2cd2040 fix(tradein): откат транзакции в backfill + неотрицательный счётчик квот (#2538)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m52s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 6m14s
2026-07-26 22:21:08 +00:00
6e132f8986 fix(tradein/estimator): граница номера дома в подборе аналогов «тот же дом» (#2534)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m53s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 7m39s
2026-07-26 22:07:00 +00:00
580be61914 fix(tradein/location): заменить сломанный коэффициент локации на калиброванный индекс (#2531)
All checks were successful
Deploy / build-backend (push) Successful in 6m30s
Deploy / build-worker (push) Successful in 6m44s
Deploy / changes (push) Successful in 11s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 2m26s
Deploy Trade-In / deploy (push) Successful in 3m10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / test (push) Successful in 5m5s
2026-07-26 21:48:15 +00:00
ca1015bd4e feat(tradein/support): веб-чат поддержки — серверная часть поверх Telegram-моста (#2532)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m59s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 1m17s
2026-07-26 20:43:47 +00:00
bot-backend
b579fa4ced feat(tradein/tgbot): Telegram support-мост @MERAsupport_bot
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage
в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот
доставляет ответ клиенту. Полный лог переписки в Postgres.

Отдельный контейнер на long-polling, а не webhook в tradein-backend:
не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и
маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API.
Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage.

Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален
в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате,
поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора
не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор.

Безопасность (найдено ревью, воспроизведено эмпирически):
- токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет;
  утекал в GlitchTip через locals стек-фреймов (include_local_variables
  по умолчанию True) и через span data HttpxIntegration. Закрыто
  include_local_variables=False + regex-редактор в before_send (обе формы:
  /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub.
- httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в
  docker logs каждые 30с. Приглушён до WARNING.

Надёжность:
- kill-switch при пустом токене — idle-блокировка, не exit(0): при
  restart: unless-stopped выход с любым кодом даёт рестарт-луп.
  unless-stopped выбран сознательно — только он гарантирует автозапуск
  после ребута VPS.
- stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше,
  чем докрутится long-poll (30с) и отработает drain (100с).
- сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset:
  иначе сессия в failed-transaction не давала сохранить offset, апдейт
  переигрывался и зеркалился в топик по кругу.

152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента
одним DELETE. Ретенция — follow-up.

Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS,
без них воркер штатно висит в idle. Порядок — в DEPLOY.md.

Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked,
throttle-окно шапки, redaction токена во всех формах event).
2026-07-16 16:58:53 +03:00