Голый except Exception вокруг per-quarter grid-walk ловил и NspdBulkWafError, при
том что его собственный комментарий обещал обратное:
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
# caller-task ловит и не ретраит, как в bulk_harvest.)
Прервать он не мог — ловил сам себя. Цикл шёл дальше по всем оставшимся кварталам,
долбя уже блокирующий WAF.
Цена. Замер прода 20.08: limit=500 участков раскладывается на 174 квартала, каждый
grid-walk делает 49 запросов. Под баном один прогон выдал бы до ~8500 обращений в
закрытую дверь вместо остановки на первом — ровно то поведение, что углубляет бан.
Соседний harvest_quarter в ЭТОМ ЖЕ файле починен предыдущей волной эпика (#2464-A)
с тем же доводом и прод-замером «23 job'а, 50 WAF-блоков, 0 упавших — то есть бан
ни разу не остановил сбор». Здесь то же место пропустили; правка приводит четвёртый
сайт к трём соседним.
Про причастность к текущему бану НСПД (#2956) не утверждаю: задача
backfill_parcel_geom_task в beat-расписании отсутствует, запускается вручную —
проверено. Но запуск её сейчас, при живом 403, дал бы ровно описанный сценарий.
Тест проверяет ПОВЕДЕНИЕ — вышла ли ошибка наружу и сколько кварталов успел
тронуть прогон. Против origin/main:
WAF-блок → вернулся GeomBackfillResult(quarters_scanned=3) вместо ошибки
rate-limit → то же самое
обычный сбой пропускает ОДИН квартал — контроль, зелёный с обеих сторон
здоровый прогон обходит все кварталы — контроль, зелёный с обеих сторон
Первый контроль не для симметрии: он ловит «починку», которая начала бы обрывать
обход на любой ошибке — тогда один битый квартал стоил бы всей ночной докачки.
Прогоны: tests/services/cadastre + tests/services/scrapers — 309 passed rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Деплой делает `git reset --hard origin/main`, поэтому любой site-блок,
вписанный руками в /opt/gendesign/Caddyfile, живёт до первого пуша в main.
16.08 так и вышло с garmin.gendsgn.ru: контейнер остался healthy, а хост
исчез из конфига — TLS-хендшейк стал падать `alert internal error`,
потому что сертификат для него Caddy больше не держит.
Класть такой блок в git нельзя: у него единственный рубеж аутентификации —
секрет в пути URL (апстрим-MCP аутентификации не имеет, а claude.ai
connector кастомные заголовки не шлёт).
Развязка: в репозитории только `import caddy/local/*.caddy` и сам каталог
(держится .gitignore'ом), файлы с секретами лежат на VPS untracked —
reset --hard их не трогает. Пустой glob для Caddy не ошибка,
`caddy validate` проходит, на машинах без локальных блоков import — no-op.
Проверено `caddy validate` 2.11.4 на VPS: и с пустым каталогом, и с
реальным блоком garmin — Valid configuration.
Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.
Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).
- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
(иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
= False внутри replay_fixture (было только в самом гейте) — иначе штатная
регенерация baseline (--from-fixture --update-baseline) писала baseline,
который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.
Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене.
## 1. Город предлагали выбрать, но отвечать по нему не умели
Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия
(`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в
двух местах. Они разошлись в обе стороны:
предлагали, но не отвечали: Серов
отвечали, но не предлагали: Берёзовский, Среднеуральск, Ревда
Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал:
«Этот адрес вне области, по которой мы собираем данные.
Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько
городов вокруг.»
Про город в той же самой области. Серов при этом покрыт данными: 363 активных
объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в
жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт.
Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением
7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест
есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс
проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый,
город по координатам не резолвится.
## 2. Подсказки не слушались выбранного города
`city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только
екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир
пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает.
Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н,
пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом —
ровно баг #2576, ради которого город и спрашивают.
Публичная ручка теперь подставляет город в саму строку запроса. Проверено на
проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга
подстановка безвредна — три разных адреса дали тот же результат с префиксом и
без, поэтому правило одно на все города, без исключения для основного трафика.
Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит
поведение закрытого контура (`target_city_ambiguous`).
## Фикстура теста
`_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от
всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до
ближайшего центроида.
## Мутации
убрать Серов из покрытия (состояние прода) → падает сверка списков
не подставлять город в строку → падает проверка ручки
откат → 21 passed
Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard.
`city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков
делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы
не запускала — то есть ровно тот путь, которым списки и разошлись.
Замер на проде сразу после выкатки #2913: страница `/estimate` отдаёт 200 и
выглядит правильно, но после гидратации монтируется `GuardedRoute` —
404 /trade-in/api/v1/me ×3
404 /trade-in/api/v1/trade-in/support/anon/unread
плюс виджет чата поддержки из закрытого контура и экран «Не удалось проверить
доступ» внизу страницы. Форма при этом переставала работать ВОВСЕ: разметка с
сервера видна, поле принимает текст, а обработчики не подключены — человек
печатает адрес и не получает ни подсказок, ни ошибки. Проверено, что дело не в
API: ручной `fetch` из той же страницы отдаёт 200 и восемь подсказок.
Причина ровно та же, что чинил PR #2910 для `/`: список публичных путей в
гварде и матчер `@meraPages` в Caddyfile — одно множество, записанное в двух
местах. Тогда разошлись на `/`, теперь на `/estimate`. Оба раза страница
отдавала 200, ломалась только в браузере, и ни один тест этого не видел.
Комментарий в шапке гварда инцидент не предотвратил (он там был), поэтому
совпадение теперь запинено ТЕСТОМ: он читает Caddyfile и требует, чтобы каждый
путь из `@meraPages` проходил `isPublicPath`. Сверяемся с источником, а не
дублируем список третий раз.
Мутация — вернуть список к состоянию, которое уехало на прод:
2 failed / 3 passed
× пропускает короткие адреса публичного домена
× совпадает с тем, что публичный домен реально раздаёт (Caddyfile)
откат → 5 passed.
Плюс vitest 56 passed, tsc, lint, isolation guard 41 файл.
`Caddyfile` уже в paths-фильтре фронтового лэйна (#2913), так что правка одного
лишь конфига теперь тоже гоняет этот гейт.
Первая версия шага смонтировала `$PWD` внутрь caddy-контейнера и упала на
`open /etc/caddy/Caddyfile: no such file or directory`.
Причина: job сам исполняется внутри контейнера, а `docker run` создаёт
КОНТЕЙНЕР-БРАТ на том же демоне. Путь в `-v` резолвится на ХОСТЕ, тогда как
`$PWD` — путь внутри job-контейнера, которого на хосте нет. Классическая
ловушка docker-in-docker, и она не зависит от содержимого конфига — смонтируй
так что угодно, монтирования просто не произойдёт.
Заменено на `docker create -w /work` + `docker cp` + `docker start -a`: копия
не зависит от того, как смонтирован workspace. Копируется и каталог `caddy/` —
Caddyfile делает `import caddy/users.caddy.snippet`, без него validate падает
на импорте.
Проверено локально обе стороны: валидный конфиг → `Valid configuration`,
exit 0; конфиг с незакрытой скобкой → `unexpected EOF`, exit 1. Без второй
проверки гейт мог бы оказаться вечно-зелёным.