Две живые копии одного модуля с противоположной семантикой counters: app
`mark_done`/`mark_failed`/`mark_banned`/`update_heartbeat` ЗАМЕНЯЛИ
(`counters = CAST(:counters AS jsonb)`), kit — МЕРЖИЛИ
(`COALESCE(counters,'{}') || …`). Расхождение дважды за сутки дало ложные
выводы на ревью (#3388 «отдать только флаг, остальное домержится» — на
replace это стёрло бы измеренное; #3355). Разошлись и другие места: гейт
статуса, `honors_cancel` у mark_cancelled (был только в app), `mark_skipped`
(только в kit), `mark_backfill_finished`/`distinct_sources` (только в app).
Реализация теперь одна — `scraper_kit.orchestration.runs`; в неё перенесены
app-only функции. `app.services.scrape_runs` — алиас kit-модуля через
sys.modules, а не реэкспорт имён: реэкспорт разводит патч-цели
(`patch("app.services.scrape_runs.sentry_sdk")`, `patch.object(runs_mod,
"mark_done")` правили бы глобаль модуля-обёртки, а тело функции читает
глобаль kit'а) — тест остался бы зелёным, не подменив ничего. С алиасом оба
имени ведут в единственную реализацию, и ни один из ~40 вызывающих и ~30
патч-сайтов в тестах не правится.
Победила семантика мержа: у строки прогона несколько писателей (пульс,
финализатор, дрейн), каждый знает лишь свои ключи, и замена теряла чужие —
чекпоинт done_buckets (#930), метку interrupted (#3391), замер из пульса
(#3384). Обратной зависимости («вызывающий рассчитывает, что финализатор
УДАЛИТ ключ заменой») нет: строка создаётся пустой в create_run, резюм читает
counters ПРЕДЫДУЩЕГО прогона по его id.
Тесты по значению на обоих путях импорта (двойник сессии читает SQL: `||`
против CAST, WHERE-гейт из текста): пульс {a:5} + mark_failed {b:1} → {a,b};
пульс/финализатор по финализированной строке — no-op; mark_cancelled
отказывает источнику, который отмену не опрашивает. На main эти тесты
красные для app-пути.
Комментарии в app/services/scheduler.py и kit/pipeline.py, утверждавшие про
живого «перезаписывающего двойника», приведены в соответствие.
Оба вызова `evaluate_via_imv` в `_get_or_fetch_imv_cached` шли без
`proxy_provider`, а `providers/_proxy.py::curl_proxy_url` считает
`use_pool = флаг AND provider is not None` — пул был выключен по построению,
curl-сессия уходила на env-прокси SCRAPER_PROXY_URL (мёртвый узел, #2613).
Провайдер берётся из уже существующего module-level импорта
`app.services.scraper_adapters` (в estimator цикла нет, в отличие от
house_imv_backfill — там lazy import вынужденный). Lease — один на вызов IMV,
acquire/release внутри `curl_proxy_url`, release в finally на всех выходах.
Пустой пул в проде (`NoProxyAvailableError`, в т.ч. завёрнутый — проверка по
цепочке причин `caused_by_no_proxy`) остаётся graceful: `_get_or_fetch_imv_cached`
возвращает None, ответ отдаётся без IMV-якоря. Причина в логе теперь честная —
«пул прокси пуст», а не «fetch failed» (запрос не уходил вовсе).
После #3392 SIGTERM-дрейн финализирует in-flight прогоны штатными
mark_done/mark_failed с counters.interrupted=1 — раньше они оставались
'running' → 'zombie' и сторожей не касались. В популяции стриков эти строки
судят частичные счётчики:
- detail-бэкфилл, убитый на 20% отказов, получал 'failed' с диагнозом
«сбор деградировал» (_failed_ratio_too_high) — диагноз про площадку,
которого никто не измерял, — и входил в стрик неудач;
- cian-бэкфилл (без attempted) получал 'done' и ОБНУЛЯЛ стрик банов —
ровно вред, задокументированный в orchestration/scheduler.py:99
(«5 банов подряд обнулил один 'cancelled' 09.08»).
Прогон с меткой interrupted теперь считается так, будто его не было: он
стрик ни продлевает, ни обнуляет (обе лестницы, обе копии модуля), а три
honest-status-гейта в mark_done пропускаются — статус остаётся 'done' с
меткой interrupted (конвенция #3319/#3333/#3355), причина в логе.
'cancelled' оставлен как был: там прогон прервал человек.
SELECT сторожа неудач дополнен колонкой counters — по ней и идёт отбор.
Пять замечаний deep-ревью к PR #3392, ровно они.
1. Пульс стирал метку дрейна. `update_heartbeat` обеих копий бил `WHERE id = :run_id`
без гейта по статусу, а app-копия counters ЗАМЕНЯЕТ (#3390): задача, помеченная
`interrupted`, но ещё живая (ветка таймаута drain_inflight отдаёт её внешнему
hard-cancel'у — несколько итераций спустя, пульс на каждый батч —
app/services/scheduler.py:141), следующим же ударом стирала метку, и оборванный
прогон снова читался как полный проход. Гейт — `IN ('running', 'cancelled')`, а не
`= 'running'`: 'cancelled' финализирует строку, но задача встаёт лишь на ближайшей
границе якоря, и её последний пульс — ЕДИНСТВЕННЫЙ писатель чекпоинта в этот момент
(pipeline.py:1308/2368/2986/4488, mark_done там уже no-op по своему гейту), а
'cancelled' входит в _RESUME_STATUSES — сужение до 'running' молча съело бы точку
возобновления у каждой отмены. Возвращаемое значение update_heartbeat не читает
никто (обе копии -> None, ни одного присваивания на 130 сайтах вызова), так что
«0 строк обновлено» ломать нечего; no-op логируется WARNING'ом, как у mark_done.
2. Отмена вне drain_inflight. Hard-cancel приходит по расписанию grace'а
scheduler_main, а не по нашему, и может застать ТЕЛО тика (reap / stale-digest /
`_dispatch` с сетевым pre_claim). `except Exception` тика CancelledError не ловит,
до `await ctx.drain_inflight()` дело не доходит — строки оставались 'running'.
Тело вынесено в `_tick_loop`, `scheduler_loop` ловит CancelledError, помечает
in-flight и пробрасывает отмену.
3. rollback в except пометки: отказавший statement оставляет сессию в aborted-tx, и
первый же непроходимый run_id утаскивал все следующие (образец — defensive rollback
в mark_failed/mark_banned).
4. session_factory()/db.close() втянуты в try: исключение оттуда ЗАМЕНИЛО бы собой
CancelledError, а suppress(CancelledError) в scheduler_main его не глушит — процесс
уходил бы с трейсбеком вместо чистого drain-выхода.
5. WARNING перечисляет marked_ids, а не весь run_ids (там были и пропущенные по
статусу). В докстринге назван потолок: SELECT синхронный, у движка нет ни connect-,
ни statement-таймаута (app/core/db.py:8-19) — недоступная БД блокирует луп до
SIGKILL'а через 20 с docker-grace; данные при этом не хуже прежних (строки остаются
'running' → boot-reap).
Тесты — по значению, не по факту вызова; на исходниках 30e3bacc краснеют все пять:
'listings_processed' дописан в финализированную строку (kit), KeyError: 'interrupted'
(app), assert 'running' == 'done' (отмена в теле тика), assert 0 == 1 (второй run_id
не помечен после отказа первого), RuntimeError наружу (недоступная БД).
Прод 07.09 02:36 UTC, первый настоящий drain после #3363: hard-cancel из
scheduler_main оборвал дрейн, и два бэкфилла (cian_detail_backfill 6167,
cian_history_backfill 6173) остались в scrape_runs со статусом 'running' —
boot-reap следующего контейнера сделал их 'zombie' (boot_reaped=true), метки
interrupted не было. interrupted=1 при дрейне писали только kit-пайплайны и
DKP-импорт: у задач, чьё тело живёт в app, ставить её было некому.
Метка ставится в единственной точке, через которую проходит любая detached
run-задача — SchedulerContext.drain_inflight: и по истечении
_CHILD_DRAIN_TIMEOUT_S, и в обработчике CancelledError (тот самый прод-путь).
run_id берётся из нового реестра {task: run_id}, который заполняет _dispatch
сразу после claim'а; claim-логика не тронута. Статус строки перечитывается
перед записью, поэтому успевший финализироваться сам прогон не
перезаписывается, а counters читаются из строки и дописываются — app-копия
mark_done их ЗАМЕНЯЕТ (#3390), голая {"interrupted": 1} стёрла бы чекпоинт.
scheduler_main: _await_scheduler возвращает признак hard-cancel'а, и строка
«scheduler drained cleanly (SIGTERM)» больше не печатается сразу за WARNING'ом
о превышении grace — на проде эти две строки стояли подряд и противоречили
друг другу.
Запас времени на запись: docker stop_grace_period 120s − _DRAIN_TIMEOUT_S 100s
= 20 с после hard-cancel'а, запись синхронная (несколько statement'ов).
Ревью нашло у цианa (в отличие от avito/домклика с живым counters.to_dict()) старый
словарь в общем except: реальные значения присваиваются уже ПОСЛЕ возврата из
backfill_cian_history, а отказ бывает и посреди неё — пул опустел между стадиями, упал
SELECT домов. Тогда поверх измеренного в запись прогона уезжали нули, и SQL-разбор
простоя (#3288/#3367) читал «к площадке не ходили» про прогон, который ходил.
Механизм оказался хуже описанного в ревью: mark_failed мержит counters (`counters ||
:counters`) только в kit-копии, а cian/avito/домклик зовут app.services.scrape_runs, где
UPDATE counters ЗАМЕНЯЕТ (scrape_runs.py:738). Поэтому «отдать только {no_proxy_stop: 1}»
стёрло бы измеренное начисто; вместо этого _heartbeat кладёт свой снимок в те же
counters (nonlocal), и в mark_failed уезжает последнее измеренное + флаг.
Тест по значению: heartbeat записал listings_processed=5, дальше пул пуст → в jsonb-
payload mark_failed должно остаться 5, а не 0 (проверяется сам payload UPDATE'а,
runs_mod настоящий). На HEAD ветки красный: `counters={'listings_processed': 0, ...,
'no_proxy_stop': 1}: нули поверх измеренных 5`.
Стаб пула приведён к проду: RealProxyProvider.acquire при пустом пуле ВОЗВРАЩАЕТ None
(scraper_adapters.py:230), а не поднимает, — исключение из провайдера глотал
`except Exception` в _acquire_lease и приходило к тому же отказу другим путём. Теперь
NoProxyAvailableError рождается там же, где в проде (browser_fetcher.py:712, ветка
`lease is None and use_pool and production`) — проверено прогоном против до-#3384
исходников: все три теста красные, трейс из _acquire_lease.
_prod_pool патчит app.core.config.settings явно + assert, что все три задачи держат тот
же синглтон: раньше патч через chb.settings выглядел настройкой одного циана.
`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug` строили
`BrowserFetcher(source="yandex", endpoint=...)` без proxy_provider/use_pool/
environment — сайдкар брал env-узел SCRAPER_PROXY_URL, на проде выключенный
(407 → camoufox InvalidIP → /fetch 503, факт #3386), то есть путь шёл мимо пула
целиком, а прод-отказ «пул пуст» (#2616) был мёртв: он смотрит на environment,
который до конструктора не доезжал. Обе точки собраны через
build_browser_fetcher(config, "yandex", proxy_provider=...), как yandex/serp.py.
Второе: общий `except Exception` в обеих функциях глотал NoProxyAvailableError и
возвращал None — прогон, не ходивший к площадке, перебирал все ЖК и уходил в
'done'. Теперь «пул пуст» пробрасывается наружу (caused_by_no_proxy), sweep
обрывается на первом доме с no_proxy_stop, а прогон финализируется как failed
(образец дефекта — #3382, cian/detail.py).
Lease берётся один раз в BrowserFetcher.__aenter__, поэтому на проде с пустым пулом
NoProxyAvailableError вылетает из самого `async with` — ДО первой карточки и мимо
стоп-механики внутри цикла (no_proxy_stop = True; break), которая и пишет
counters.no_proxy_stop. Общий `except Exception` ловил его и делал mark_failed с
нулевыми counters без ключа: прогон, который к площадке не ходил вообще, в SQL-разборе
простоя по counters.no_proxy_stop (#3288/#3367) не находится.
Дыра одинаковая у всех трёх бэкфиллов (avito её тоже не обрабатывал: __aenter__
вызывается напрямую строкой 427, отказ уходит в тот же общий except). Правка — в трёх
уже существующих обработчиках, которые и так зовут mark_failed: ключ no_proxy_stop=1
при caused_by_no_proxy(exc). Оборачивать `async with` в try/except пришлось бы с
переносом ~200 строк тела под новый отступ в каждом файле, и покрывало бы только
падение на входе; здесь ловится любой путь мимо цикла.
Тест — через настоящий BrowserFetcher: подделан только провайдер прокси (его acquire
поднимает NoProxyAvailableError), отказ рождается там же, где в проде. Проверяется
failed + no_proxy_stop=1 + attempted=0 (у циана listings_processed=0) и ноль POST'ов
в сайдкар.
Closes#3384
Ревью нашло, что миграция и код ловили РАЗНОЕ. Миграция брала базой предыдущую
СЫРУЮ строку (lag), гейт — предыдущую ОСТАВЛЕННУЮ. На 1M→10M→1M→10M (цена 1M)
lag-версия удаляла честную точку, на 1M→10M→1.05M→9.9M — не была идемпотентной
(второй прогон доедал 9.9M). Теперь кандидаты выбирает PL/pgSQL-цикл, пошагово
повторяющий drop_decimal_slips, а правило первой точки — отдельным INSERT..SELECT
уже по ОСТАВШИМСЯ строкам.
Правило первой точки — из прод-разбора: 12 из 20 остатков domklik это серии вида
330 000 → 3 300 000 (текущая цена 3 300 000) и 420 000 → 4 200 000 → 4 500 000,
где дефектная точка ПЕРВАЯ и базы слева у неё нет. Свидетелей по-прежнему два:
×10 ко второй точке И подтверждение второй третьей-или-текущей-ценой. Решение по
первой точке принимается по kept-серии, а не по сырой, — иначе гейт теряет
идемпотентность (перебор ловит 1122 таких прогона).
Идемпотентность доказана НА ГЕЙТЕ: property-тест gate(gate(s)) == gate(s) по всем
сериям длины 2-6 (19 525 серий × 3 текущие цены). Фальсифицирован обеими
поломками — сырая база даёт 136 красных прогонов, сырые соседи первой точки 1122.
Раз SQL зеркалит гейт, свойство переносится на миграцию.
Ещё в 286: третий свидетель ПРОТИВ удаления (цена подтверждена триггерной строкой
того же объявления — значит она реально наблюдалась в listings.price_rub) и
финальный шаг |diff_percent| > 100 → NULL по всем источникам, то же правило, что
validate_diff_percent на записи. Ожидаемое число удалений в шапке — 35 + ~12 из
двухсвидетельского предзамера, а не 76 (то была односвидетельская цифра).
Прогон обеих фаз дважды с ROLLBACK — tradein-mvp/scripts/sql/286_dryrun.sql.
yandex: проводка гейта снята как мёртвая. На том пути серия из двух точек, а
свидетель последней — текущая цена лота, то есть она же сама: ветка по построению
не могла выбросить ничего. Оставлен честный комментарий-потолок и ссылка на
follow-up (отлов требует DELETE на следующем наблюдении).
cian: после выброса точки соседу пересчитывается diff_percent (было только у
domclick). domclick: цена листинга берётся RETURNING'ом у UPDATE вместо отдельного
SELECT по PK, пересчёт вынесен в общий recompute_diff_percent с гейтом на пустую
цену (ручной ingest кладёт price_changes из JSONL без валидации).
В priceHistory источников встречаются точки ровно ×10/÷10 к соседям с
возвратом к базе следующей же точкой — это потерянный разряд у источника,
а не рынок. Прод-замер 06.09.2026: 76 таких строк у 55 объявлений
(domklik 48, cian 21, yandex 6; единственная avito-строка — триггерная).
Читатели колонки — медианный торг лендинга (#3223), админка, /scrapers.
drop_decimal_slips живёт рядом с validate_diff_percent — на той же единой
границе записи, что и гейт #3225, и подключён ко ВСЕМ писателям истории
(domclick/detail.py, cian/detail.py, yandex_price_history.py). Критерий
требует двух свидетелей: скачок ×10 к предыдущей точке И возврат к базе у
следующей; у последней точки серии свидетель — текущая цена объявления,
нет и её → точку не трогаем (без второго свидетеля ×10 может быть честной
сменой цены). У domklik после выброса пересчитывается diff_percent
соседа: парсер считал его от базы, которой больше нет.
Миграция 286 чистит уже собранные строки тем же критерием и только у
загрузчика (change_time <> recorded_at): триггерные строки — это живые
смены listings.price_rub, у них другая база отсчёта (см. 285). Падает,
если кандидатов больше 200.
BrowserFetcher(source="cian") в cian_history_backfill конструировался без
proxy_provider/use_pool/environment — трёх аргументов, которые кладут "proxy" в тело
POST /fetch. Сайдкар брал свой env-прокси (SCRAPER_PROXY_URL): пул из 4 узлов, его
баны и ротация проходили мимо, а прод-отказ «пул пуст → не ходить на env/direct»
(#2616) на этом пути был мёртв, потому что смотрит на environment. Проводка теперь
как у соседей — domclick_detail_backfill и house_imv_backfill.
Ожившему отказу нужен обработчик: NoProxyAvailableError ловился общим except на
объявление, и батч крутил впустую весь список (пул пуст с первого — значит пуст и на
1000-м). Распознаём по цепочке причин, обрываем прогон, counters.no_proxy_stop=1 и
mark_failed вместо mark_banned — отказ нашей стороны не должен записываться как бан
Циана. Дома и оценки после стопа пропускаем: они идут через тот же пул.
caused_by_no_proxy вынесен в scraper_kit.proxy_errors (у avito #3288 и domclick #3283
живут приватные копии — их схлопывание отдельной правкой).
#3360. Периметр /trade-in/api/v1/admin/* снаружи не срезан (caddy/sites/apps.caddy:
блок `handle /trade-in/api/*` стоит выше `import caddy/users.caddy.snippet`), и
срезать его нельзя: admin-UI кабинета зовёт эти пути ИЗ БРАУЗЕРА (12 файлов
tradein-mvp/frontend/src — scrapers/**, components/scrapers/**, admin-audit-api.ts,
GuardedRoute). Значит внешний аноним доходит до rbac_guard, а после #3324
(несуществующий путь → 404 роутера) 401 на существующей ручке стал оракулом:
перебором имён восстанавливался список admin-API.
Все три ветки «личность не установлена» (нет X-Authenticated-User, auth_mode=db_only,
подделанный заголовок без #2213-секрета) на admin-префиксе теперь отдают ровно то же,
что роутер даёт на несуществующий путь. Аутентифицированные не тронуты: admin — 200,
не-admin — 403 «admin only» (прятать наличие ручки от опознанного человека незачем).
Смоук периметра: пара admin-путей — существующий /admin/proxies и несуществующий
/admin/users — оба обязаны быть 404 снаружи.
Прошлая правка понижала banned→failed/done по одному диагнозу infra и ломала
обратный контракт: нулевой прогон с infra (yandex 5xx #3196, финализатор #2764)
получал 'failed' — настоящий бан площадки, опознанный как infra, прятался под
«нашу поломку». Хуже исходного дефекта: 2 красных теста в полном прогоне.
Понижение сужено до случая прогона 5425 — dominant='infra' И produced > 0:
брейкер оборвал по доле, а карточки при этом обогащались → 'done'. Нулевой
прогон остаётся 'banned' (честность несёт ban_kind), пустая перепись — тем
более: dominant='unknown', статус не трогаем.
Тесты: контроль на обратную ошибку (ноль результата → banned+infra) и на
пустой census (→ banned+unknown); основной кейс {'infra': 20} при 10
обогащённых — не banned.
Ревью #3364. Требуя именно encryptedPhones, отбраковывали бы вечно
необмеренный класс карточек «без телефона / только чат»: в очередь они
возвращаются, а ключ не появится. redirectPhones измерен тем же замером
#3192 и присутствует в обеих ветках (с куками и без).
Текст ABORT: consecutive_none смешанный (фетч-ошибка + parse-None +
недогруз) — «N подряд без обогащения», а не «недогруженных».
Три дыры в одном замке (строка без payment_url невидима для _find_live_payment,
но видима предикату UNIQUE 279 → ложный 409 на 30 минут):
- tbank_client ловил пару (TimeoutException, NetworkError): RemoteProtocolError,
ProxyError и UnsupportedProtocol летели наружу голым httpx-типом мимо
`except TBankApiError` в checkout. Ловим родителя — httpx.TransportError.
- Ветка «Success:true без PaymentURL» отвечала 502, не трогая статус, — здесь
банк заказ ПРИНЯЛ. Тот же терминальный статус, error_code=no_payment_url;
UPDATE вынесен в общий _mark_init_failed.
- _FakeDb в тестах применял статус по наличию ключа в params, а не по тексту
SQL: мутант без `SET status = :status` оставался зелёным. Гейт по SQL —
мутант краснит все три теста про замок.
Комментарий про «возможный холд» на ветке отказа Init поправлен: Init холд не
создаёт, авторизация идёт с оплаты формы, а форму покупателю не выдавали.
Прогон 5425 оборвался по доле блоков и получил статус banned на 48 «блоках»,
из которых 41 был отказом нашего сайдкара: record_block() вида не принимал,
поэтому infra падал в числитель скользящего окна #3184 наравне с настоящим
баном, а mark_backfill_finished считал диагноз только ради телеметрии.
- record_block(kind): в числитель идёт только platform; всё остальное — в
знаменатель (как record_failure), мимо серии и safety-net;
- NoProxyAvailableError у avito — не блок и не отказ площадки: прогон
завершается no_proxy_stop=1 + mark_failed «пул прокси пуст» (как домклик
после #3283); опознаётся по цепочке __cause__, не по подстроке (#3272);
- статус banned — только при доминировании platform; при infra прогон
получает failed (нулевой результат) или done, с честной причиной.
Страница на 1,8 МБ без блока контактов приходит с HTTP 200 и валидным HTML:
window.INITIAL_STATE на месте, parse отрабатывает — и частичная карточка уезжала
в БД с detail_enriched_at, выбывая из очереди навсегда. Единственная проверка
размера (newbuilding.py, len(html) < 500) отвечала на вопрос «пришло ли хоть
что-то»: 1,8 МБ проходит её в 3600 раз.
Признак полноты структурный + размерный, любой из двух даёт отказ:
encryptedPhones (65 вхождений у полных карточек, 0 у недогруза; отдаётся и
анонимной сессии — см. yandex_session.py) и settings.yandex_detail_min_html_bytes
(1 МБ). Наблюдавшийся недогруз ловит именно структурный: 1,8 МБ порог проходит.
В backfill проверка стоит ДО parse: исход incomplete ⊆ failed, save не
вызывается, значит detail_enriched_at не проставляется и следующий снапшот
(detail_enriched_at IS NULL) возьмёт объявление снова. Серия недогрузов двигает
consecutive_none — тот же брейкер, что у parse→None, поэтому вечно недогружаемая
карточка обрывает прогон, а не молотится (per-listing счётчика попыток в схеме
нет).
Фейковые ответы в тестах-соседях (#3196/#3338) теперь при HTTP 200 выглядят
полной страницей — иначе они молча стали бы кейсами про полноту.
Строка после провального Init оставалась в NEW и без payment_url: для
_find_live_payment (фильтр payment_url IS NOT NULL) её нет, а для предиката
UNIQUE миграции 279 — есть. Следующий checkout ловил конфликт и получал 409
'retry shortly' до истечения _ABANDONED_AFTER_MINUTES — из-за сбоя банка, а
не своего действия.
Переводим такую строку в терминальный DEADLINE_EXPIRED (тот же, которым
checkout уже помечает брошенные попытки) в том же UPDATE, что пишет
error_code/error_message: статус вне предиката 279, пара
(estimate_id, product_code) освобождается сразу. Новый статус в CHECK 233 не
заводим — миграция ради ярлыка не нужна, причина и так в error_*.
Refs #3323
Ревью #3352: комментарий у _path_is_routed утверждал «ослабления нет» — неверно.
Раньше 401 был ПРЕФИКСНЫМ оракулом (таблицу маршрутов по нему не перечислить),
теперь 401/404 — оракул СУЩЕСТВОВАНИЯ маршрута, включая имена admin-ручек.
Докстринг переписан честно, с проверенным по caddy/sites/apps.caddy фактом:
блок handle /trade-in/api/* стоит выше import users.caddy.snippet, внешнего
basic_auth у trade-in нет — значит перебор имён выполним и снаружи.
Второй канал того же оракула закрыт: /api/v1/me/ не матчил ни один маршрут,
guard пропускал, а роутер отвечал 307 на существующий путь. FastAPI получил
redirect_slashes=False (проверено: ни одного route с трейлинг-слэшем, ни одного
такого вызова во фронте; deny-правила уже на глоб-форме).
Тесты: PARTIAL-кейс (POST на GET-путь анониму → 401, не 404/405), трейлинг-слэш
на РЕАЛЬНОМ app.main (тест на копии был бы тавтологией), admin-гейт сверяется по
тексту 'admin only' — scope-ветка отвечает тем же 403, но 'forbidden for role'.
Ревью #3347, два minor.
1. avito: WHERE в save_detail_enrichment ключуется по source_id из РАЗОБРАННОГО
HTML (enrichment.item_id), а warning печатал row["id"] из снимка. При
редиректе/подмене карточки строка с row-id жива, и читатель лога идёт искать
несуществующую проблему не у той строки («текст ошибки называет гонца»).
Печатаем оба: listing_id=%s item_id=%s.
2. yandex: listing_id стоял в строке дважды (в начале и как «id=%d» в хвосте) —
убран дубль.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ревью-minor: якорь [?&] вплотную к имени пропускал client_secret=,
refresh_token=, auth_token=, webhook_secret=. Разрешаем префикс [\w.-]*
перед альтернацией — маскируем имена, ОКАНЧИВАЮЩИЕСЯ на чувствительное
слово. Кейс not_a_secret_name заменён на честные отрицательные:
?secretary= и ?tokens_page= (не оканчиваются на secret/token) плюс
/api/v1/token-info в пути.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
uvicorn печатает в access-log полный путь вместе с query, поэтому секрет
вебхука (`?secret=`, он же TRADEIN_INTERNAL_AUTH_SECRET, второй рубеж rbac)
уезжал в Loki открытым текстом. Скруббер #3115 в Alloy ловит только форму
`user:pass@host` (DSN postgres_exporter) и такую строку не закрывает.
Два слоя:
- хендлер принимает секрет из заголовка X-GlitchTip-Secret; query-параметр
остаётся fallback'ом, т.к. сам GlitchTip 6.1.6 (`send_webhook()`)
заголовков не шлёт вовсе — убрать query можно только когда заголовок начнёт
подставлять кто-то перед нами (Caddy header_up) или сменится отправитель;
- app/core/log_scrub.py: logging-фильтр маскирует значения чувствительных
query-параметров (secret/token/api_key/…) на uvicorn.access и на
обработчиках корневого логгера — секрета нет уже в `docker logs`.
Сравнение секрета и было constant-time (`secrets.compare_digest`).
rbac_guard — HTTP-middleware, он отрабатывает до роутинга и потому отвечал
401 с rbac-текстом даже на пути, которых в приложении нет. Аноним получал
бесплатный оракул периметра: мусор под «интересным» префиксом давал 401, а
такой же мусор под публичным префиксом — 404 роутера, то есть выключенная
ручка была отличима от несуществующей.
Guard теперь пропускает запрос дальше, если ни один маршрут роутера не
матчится (Match.NONE у всех) — 404 отдаёт тот же роутер, что и на любой
другой мусор. Существующие маршруты не затронуты: Match.PARTIAL (путь есть,
метод другой) по-прежнему идёт в guard, реальный закрытый маршрут анониму
даёт 401, публичный — работает без идентичности.
Тот же дефект, что #3332 у domclick (#3335), у обоих соседей: `if save(...):
enriched += 1` без else. UPDATE, не задевший строку (объявление удалено между
снимком и записью), оставлял попытку без исхода — attempted переставал сходиться
с суммой исходов, и расхождение читается как потерянный отказ площадки.
Разбор ВСЕХ точек выхода из цикла попыток показал, что у соседей это
единственная дыра: обрыва «пул прокси пуст» у них нет (resolve_proxy_url
бросает ProxyPoolExhaustedError ДО цикла), а budget/SIGTERM-брейки стоят до
`attempted += 1`. Исход — failed с отдельным warning про ненайденную строку.
Формы тождества разные и это не описка: у yandex blocked ⊆ failed (#3196),
у avito blocked/gone/failed — непересекающиеся корзины.
Тесты — по значению (числа, не «не бросило»), плюс контроль на противоположную
ошибку (исход не начисляется дважды). В test_3332 добавлен запрошенный ревью
кейс: транспортный сбой → пустой пул даёт attempted=2 failed=2, а не 3.
Closes#3338
Веб-отчёт и лендинг с #3342 не показывают названия площадок (канон publicLabel в
frontend/src/lib/source-registry.ts), а клиентский PDF по тому же /estimate/{id}
печатал Avito / Циан / «Домклик · Сбер» / Я.Недвижимость / Этажи в пилюлях
источников, «подтверждают Росреестр, ДомКлик…» в советах и «на Циан, Авито,
Я.Недвижимости» в тарифах. Один клиент — два документа с разной нормой, и юр-риск,
ради которого всё делалось, в PDF оставался открытым.
- `_SOURCE_DISPLAY_NAMES` → публичные лейблы 1:1 с реестром фронта: avito → «Источник 1»,
cian → 2, yandex → 3, domklik → 4, etazhi → 5, rosreestr → «Росреестр»; fallback для
незнакомого id — «Другой источник», а не `source.title()` (сырой id — та же утечка).
- Алиасы (avito_imv, cian_valuation, yandex_valuation, domclick, etagi) канонизируются
ДО выбора цвета и лейбла (`_canonical_source`), а списки источников на страницах
объявлений и сделок дедуплицируются до среза [:5] (`_public_sources`): иначе
`sources_used` = listing ∪ valuation давал «Источник 1, Источник 1, Источник 2,
Источник 2, Источник 4» с серой точкой у алиасов и вытеснял yandex.
- Цвета пилюль не тронуты: цвет — опознаватель источника, как на вебе.
- Тексты: «Росреестр, сделки площадок и продажи агентств», «на основных площадках
объявлений» (двойник offer-rates.ts).
- Гейт `tests/test_pdf_public_source_labels.py`: видимый текст страниц (без тегов и
атрибутов, href на домены площадок законны) не содержит названий площадок и сырых
id, по одному кейсу на имя; дедуп алиасов; ветка совета с процентом.
Не тронуто: ссылки на объявления (avito.ru/domclick.ru) — отдельное решение;
`_QUALITY_SOURCE_SLOTS` — только счётчик, имена не рендерит.
Три юр-правки по документу владельца продукта «Сайт_МЕРА_v2» (31.08.2026), сделаны
локально 31.08, но не были закоммичены — на meraocenka.ru всё оставалось по-старому.
1. «Оценщик» про собственный алгоритм. Витрина сделок (`landing_showcase_deals.py`,
REJECTION_RULE/NOTE) и сноска статьи «Как оценить квартиру» теперь говорят
«расчёт МЕРЫ»: самоназвание обесценивало дисклеймер «не официальный отчёт оценщика».
Комментарии и докстринги не тронуты — посетитель их не видит.
2. «Путь 2»: «стоимость услуг фиксированная и известна заранее» — читалось как
фиксированная цена квартиры.
3. Названия площадок убраны из видимой копии лендинга и веб-отчёта. Канон —
`publicLabel` + `sourcePublicLabel()` в `source-registry.ts`: одна площадка = один
номер «Источник N» и один цвет точки (цвет остаётся опознавателем между блоками),
Росреестр под своим именем, неизвестный id → «Другой источник» (раньше fallback
отдавал сырой id). Три параллельные реализации `sourceLabel` сведены к одной;
`sourceLabel()` с реальными именами живёт для админки. Backend: якорь
confidence_explanation «по оценке Avito IMV» → «по оценочной модели площадки».
Подсказки геокодера «Yandex / Nominatim» и «по Яндексу» сняты из клиентских форм.
Гейт `public-copy-no-platform-names.test.ts` сканирует mera-public/** и
components/trade-in/** без комментариев, по Unicode-границе слова; список запретов
регистрозависимый намеренно (строчные id `"avito"` законны), поэтому капс-варианты
и словоформы перечислены явно — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» в статье именно так
проходил первую версию гейта.
Не закрыто здесь: клиентский PDF (#3341) и ссылки на объявления на доменах площадок.
Текст витрины хранится в БД (`landing_showcase_runs.rejection_rule`,
`landing_showcase_deals.note`), планировщика у пересчёта нет — после деплоя нужен
ручной пересчёт или UPDATE трёх подстрок на проде.
MEDIUM-1: imv_anchor_present ставился по anchor_total МИМО гейта — на тонком
рынке якорь отброшен, а Guard-1b (#764) продолжал глушить квартальную поправку
«потому что якорь есть»: headline не получал ни одной поправки, отброшенный
якорь двигал деньги вычитанием. Теперь present = not thin_market.
MEDIUM-2: trade_in.py (GET ?id= — расшаренная ссылка/PDF) — третья точка
сборки карточки: market_count=0 читался как «неизвестно», thin_market не
передавался вовсе → одна оценка показывала thin_market=True в POST и False
при переоткрытии.
avito_imv_thin_market_threshold рождал только warning: IMV с market_count=1
всё равно уходил в blend (w=0.5 при A > median*1.15) и растягивал range_high.
Гейт поставлен в _apply_imv_blend — единственной точке, через которую IMV
влияет на деньги (обе ветки якоря, imv_anchor и imv_eval, сходятся там):
market_count < threshold → no-op, якорь остаётся display-only в карточке.
market_count >= threshold и market_count=None (порог не передан) — поведение
прежнее. market_count=0 больше не читается как «неизвестно».
Warning теперь говорит, что IMV ОТБРОШЕН, а не просто «тонкий рынок».
Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода,
поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1
(прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не
ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев
(#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed.
Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана,
но строки уже нет — попытка была, исхода не было.
Closes#3332
Review PR #3331: приёмка «роли не изменились» гонялась с ПУСТЫМ реестром, а в
проде строка в БД есть у 12 из 13 юзеров и DB-роль ИНАЯ (kopylov: manager при
YAML pilot, user1: employee при YAML pilot). Добавлены два кейса именно этой
конфигурации:
* YAML pilot + реестр employee → employee, и scope не поехал: allow/deny
DB_ROLE_PATHS['employee'] сверяются со списками роли pilot из roles.yaml
целиком — дрейф ЛЮБОГО из двух списков теперь красный тест, а не тихо
потерянный/выданный раздел в проде;
* YAML pilot + реестр manager → manager, и лишних путей на tradein-периметре
нет: manager отличается от employee ровно префиксом /api/v1/team/** (вне
/trade-in/**), deny-списки совпадают.
Докстринг `_registry_role`: зафиксирован компромисс — при недоступном реестре
фолбэк временно возвращает авторитетность roles.yaml, то есть состояние, которое
фикс и лечит. Сегодня безопасно (прод-коллизий имён нет, новые закрыты
409-гвардом create_employee); появится коллизия — ветку менять на fail-closed.
Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`),
а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не
сверял. Дефект двусторонний:
* вверх: менеджер заводил сотрудника с именем, которое уже числится в
roles.yaml админом (проверялись лишь regex и уникальность в БД) — на
входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки
(admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту;
* вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на
СОБСТВЕННУЮ оценку.
Источник теперь один и лечится один раз — в `app.core.auth.get_role`:
реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым,
roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре
нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход.
Вызывающие (rbac, trade_in, team, account_quota) не менялись.
Сопутствующее, чтобы поведение существующих аккаунтов не поехало:
* rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS),
иначе employee/manager на legacy-пути получил бы 403 на всё;
* get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS;
* право на персональный `unlimited` осталось за roles.yaml (account_quota +
_batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую;
* `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список
«Команды» снова стал бы N+1.
Defense-in-depth: create_employee отдаёт 409 на username, за которым в
roles.yaml числится не-employee роль.