Сбор Яндекса по Москве уже идёт, а лить его было нечем: в SOURCE_VIEWS стояли
только cian и avito.
Отбор Москвы у Яндекса не требует ни префикса округа (как у Циана), ни
пред-геокода (как у Авито). Адрес приходит полным и нормализованным — «Россия,
Москва, Коробейников переулок, 1», регион читается вторым компонентом. Замер по
21 393 карточкам первого прохода: во втором компоненте ровно ДВА значения,
«Москва» 10 610 и «Московская область» 10 783, третьего не встречается. Новая
Москва отдельным значением не приходит — Троицк и Зеленоград Яндекс кладёт под
«Москва», что совпадает с кодом региона 77.
Координаты, адрес и ссылка заполнены у 100% карточек, поэтому geom появляется
сразу и ждать ночного `geocode_missing` не нужно. `--geocode` для yandex
отклоняется так же, как для cian: квота нужна только Авито.
`filter_by_okrug` заменён словарём CITY_FILTERS — источник либо сам говорит про
город, либо его в словаре нет и без пред-геокода писать его нельзя. Поведение
cian и avito байт в байт прежнее.
`uv run python -m pytest tests/test_msk_raw_import.py` — 42 passed, ruff чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Post-merge прогон main после #3440 дал 5 failed: `_fetch_deals() got an
unexpected keyword argument 'rooms'`. Семантический конфликт мержа, а не
поломка: ветка отводилась до #3256, который УБРАЛ параметр `rooms` (сделки
Росреестра комнатность не несут). Текстового конфликта git не увидел, pre-merge
CI ветки был зелёным, красным стало только на объединённом дереве.
В проде эффекта нет: `_fetch_deals` из `app/` не вызывается ни разу ни до, ни
после мержа — функцию держат только эти тесты.
`uv run python -m pytest tests/test_3051_moscow_okrug_bands.py` — 24 passed,
ruff чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Джоба `changes` воркфлоу ci.yml валила PR по собственному правилу репозитория
(#2752): блокирующий DDL без `SET LOCAL lock_timeout` встанет в очередь за чужой
сессией и уведёт за собой запросы приложения. Нарушение было в 299 и до правки
схемы — просто до гейта раньше не доходило.
Добавлена первая строка после BEGIN, как в 36 соседних миграциях. Повторно
проверено на пустой базе: применяется с ON_ERROR_STOP=1, пять таблиц на месте.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
`test_dkp_corridor_keeps_full_area_band` проверял только присутствие полосы ±15% в
bind-параметрах — она есть и на origin/main, и на варианте с бакет-ключом, поэтому
тест был зелёным по построению и ничего не охранял (мутационная проверка: при
восстановлении предиката он оставался зелёным, пока остальные 4 краснели).
Утверждение усилено до «полоса единственная»: тест дополнительно требует отсутствия
предиката по rooms рядом с ней. На origin/main фильтров по площади ДВА (полоса и
бакет через d.rooms), итоговое окно — их пересечение, поэтому теперь тест краснеет
значением вместе с остальными.
Refs #3256
CI Trade-In падал до единого теста: «schema "msk_raw" does not exist», job
backend-tests, run 10854. Схему и первые две таблицы (`batches`, `avito_cards`)
заводили на проде руками 08.09 — сбор сырья стартовал раньше модели данных, и
msk_raw сознательно жила вне линейки миграций. На чистой базе CI этого контекста
нет, а 299 сразу создаёт таблицы внутри схемы и вешает внешний ключ на
`msk_raw.batches`.
DDL продовских объектов повторён в 299 идемпотентно, определения сняты
`pg_dump -s -n msk_raw`, чтобы CI и прод не разъехались молча. На проде это
no-op. Проверено на пустой базе migtest_msk: применяется с ON_ERROR_STOP=1,
даёт все пять таблиц и четыре вью, повторное применение проходит без ошибок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Разворот предыдущего коммита ветки (a780e3e6) на корень: вместо подстановки
`area_bucket(area)` в предикат `d.rooms = ...` предикат УДАЛЁН во всех трёх местах.
ПОЧЕМУ НЕ БАКЕТ. `deals.rooms` — синтетика из площади (321 559 из 321 560 сделок
удовлетворяют `rooms == area_bucket(area_m2)`, max(rooms)=4), значит `d.rooms = X`
тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`. Это ВТОРОЙ, ступенчатый фильтр
по площади поверх полосы `area_m2 BETWEEN :area_min AND :area_max`, стоящей строкой
ниже. Прод-замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12) — какая
доля полосы ±15% переживает предикат:
d.rooms = комнаты клиента медиана 77.8%, у 180 запросов полоса вырезана ЦЕЛИКОМ
(пересечение пусто ⇒ коридора нет никогда)
d.rooms = area_bucket(area) медиана 90.0%, пустых нет, НО у 902 из 1179 полоса
всё ещё усечена: 44.0 м² → сохраняется 50% полосы,
62.0 м² → 50%, 82.6 м² → 59.7%. Величину усечения
задаёт не модель, а случайное положение метража
относительно границ 30/44/62/85.
без предиката 100% по построению
Т.е. бакет-ключ чинит катастрофический случай (пустое пересечение) и оставляет
произвольное усечение у 76.5% запросов. Полоса ±15% уже выражает «похожие по
площади сделки» — второго фильтра по тому же признаку быть не должно.
ЗАМЕР ЭФФЕКТА НА ЦЕНУ (1179 запросов, все три пути влияния коридора на headline:
cap/floor, sufficiency-гейт #oblast-E, deals-headline-fallback; листинговая сторона
берётся из сохранённой оценки, коридор пересчитан на сегодняшнем снимке deals для
всех вариантов, поэтому сравнение apples-to-apples; реплика сверена с ПРОДОВЫМ SQL
на 58 оценках × 3 варианта — 174/174 совпадений):
коридор доступен n>=3: 769 → 850 (бакет, +86/−5) → 874 (без ключа, +105/−0)
n>=10: 567 → 623 (бакет, +66/−10) → 691 (без ключа, +126/−2)
сдвиг headline vs текущий прод бакет без ключа
клиентов сдвинулось 28 120
медиана сдвига +1.0% −1.6%
p10 / p90 −30.6% / +6.1% −8.6% / +4.3%
сдвиг > ±10% 10 (все вниз) 10 (7 вниз, 3 вверх)
сдвиг > ±25% 4 2
по путям (медиана сдвига): бакет без ключа
cap/floor, радиусная медиана +3.9% (p10 −36.3%) −1.7% (p10 −5.9%)
cap/floor, якорь Tier C −10.1% (5 сдвигов, 4 из них >10% вниз) −0.8%
sufficiency-гейт −0.4% +0.3%
deals-fallback +3.5% (p10 −20.8%) −0.1%
якорь Tier A 0 (коридор не влияет: cap exempt, floor требует
anchor_tier is None)
Вариант без ключа даёт больше покрытия (+105/−0 против +86/−5), сдвиг с медианой
около нуля и БЕЗ кластера сильных падений, тогда как бакет-ключ несёт кластер
Tier C с медианой −10.1%. Худший случай (−41.8%, Малышева 84, 1к/54 м²: премиальный
лот прижимается cap'ом к коридору улицы) ОБЩИЙ для обоих вариантов — он появляется
от самого факта наличия коридора, а не от выбора ключа.
УТОЧНЕНИЕ ФАКТА ИЗ a780e3e6: «у 818 клиентов выборка не меняется» — неверно, их
793. Скрипт классифицировал через `min(max(rooms,0),4) == area_bucket`, из-за чего
27 клиентов с 5-6 комнатами попали в «совпадающие», хотя у них выборка меняется с
пустой на непустую. (Практического прироста они всё равно не получают: их метраж
158-456 м² в основном вне окна импорта `area BETWEEN 18 AND 200`.)
ЯКОРЬ ПРОТИВ МОЛЧАЛИВОГО ВОЗВРАТА. Ни один тест не краснел, если импортёр начнёт
писать настоящую комнатность. tests/test_3256_deals_rooms_key.py теперь ПАРСИТ CASE
из deploy/import-rosreestr.sh и сверяет его границы с `area_bucket()` (поточечно, на
границах и между ними); у самого CASE стоит комментарий-якорь «поменяешь на реальную
комнатность — вернись в #3256».
Каверза (e) харнеса: формулировка «бакеты 0-2 чисты» УБРАНА как неверная. Замер по
тому же пулу, который видит `_fetch_analogs` (свежесть 14 дней, вторичка, регион 66):
совпадение rooms == area_bucket — бакет 0: 69.8%, 1: 63.5%, 2: 60.1%, 3: 54.6%,
4: 30.9%. В бакетах 0-3 модальная комнатность совпадает с бакетом, в бакете 4 — нет
(мода 3, 54.5% пула). Добавлена перекрёстная ссылка: каверзы (d) и (e) СКЛАДЫВАЮТСЯ
(неправильное МЕСТО + неправильный СЕГМЕНТ), а не спорят.
Логи витрины `/street-deals` называли `rooms=%d` комнатностью клиента, хотя фильтра
по ней в запросе уже нет — теперь печатают фактический ключ (полосу площади), а
комнатность помечена как контекст запроса.
НЕ входит в этот PR (заводится отдельно): TVF `street_sales_vs_listings`
(data/sql/211_*.sql:89,113) — там асимметричный ключ (`d.rooms` синтетика,
`l.rooms` настоящая), копипастой не чинится; каверза (e) для
app/tasks/landing_showcase_deals.py:415/426.
Refs #3256
Единственная правка ветки, которая меняет РЕЖИМ ОТКАЗА при исчерпании пула:
было «медленно» (ждём коннект до 30 с), стало «быстро с ошибкой» (5 с и
`sqlalchemy.exc.TimeoutError` → 500, глобального обработчика в app/main.py нет).
И едет она во все сервисы образа — backend, scraper, tgbot
(tradein-mvp/docker-compose.prod.yml), для скраппера и бота обоснования в коде
нет: за 29 ч логов исчерпания пула не было ни разу, проверить новое значение на
проде пока не на чем.
Поэтому коммит последний в ветке: ветку можно мержить без него, а на проде —
откатить одной командой (`git revert`).
Обоснование самого значения: чекаут коннекта нельзя прервать `asyncio.wait_for`,
он занимает поток `asyncio.to_thread` целиком, а пул потоков конечен
(min(32, cpu+4)) — исчерпанный пул коннектов превращается в исчерпанный пул
потоков. 5 с короче самого короткого бюджета источника (8 с Yandex/Cian/
house_meta; geocode 12 с, IMV 20 с — длиннее): занятый пул деградирует ОДИН
источник, а не весь запрос.
ТРИГГЕР ОТКАТА (вернуть 30 с) записан в комментарии рядом со значением: любое
`QueuePool limit ... timed out` в логах бэкенда ЛИБО рост failed+zombie в
`scrape_runs` после деплоя.
Правка комментария по ревью (L2): «вчетверо больше любого бюджета внешнего
источника (8 с)» было неточно — бюджеты 8 / 12 / 20 с, перечислены явно.
Гейт `test_pool_checkout_wait_shorter_than_source_budget` переехал сюда же (в
коммите без `pool_timeout` он был бы красным) и читает публичный
`engine.pool.timeout()` вместо приватного `pool._timeout`.
Refs #3083, #3408
Ревью PR #3444, M1. `_with_budget` — это `asyncio.wait_for`, а `asyncio.to_thread`
отменить нельзя: снимается только ожидание со стороны loop'а. Корутина умирает,
поток продолжает работать с ТОЙ ЖЕ `Session`, а вызывающий тем временем идёт
дальше по своим шагам ПО ТОЙ ЖЕ сессии — следующий источник,
`_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной сессии
дают «another operation is in progress» / InvalidRequestError на следующем шаге
БД: у источников её глушит `except` вокруг вызова, у персиста оценки не глушит
ничего — 500 и потерянная оценка клиента, ровно под нагрузкой, ради которой PR и
делается.
`_db_step` теперь пробрасывает отмену ПОСЛЕ того, как поток отпустил сессию
(`asyncio.shield` + ожидание шага). Цена — бюджет источника переезжает на длину
ОДНОГО шага БД, а не на длину фетча, ради которой бюджет заведён.
Почему не `threading.Lock` на сессию (вариант из ревью): лок внутри `_db_step`
сериализует только шаги, которые через `_db_step` и проходят, — а названный
пострадавший `_persist_estimate_and_commit` (estimator.py:5203) это ГОЛЫЙ
`asyncio.to_thread(db...)`, как и ещё 17 мест эстиматора; лока они не берут, и
дыра осталась бы открытой ровно там, где она стоит 500. Ожидание же в точке
отмены закрывает ВСЕХ последующих потребителей сессии разом и не заводит
глобального состояния (`WeakKeyDictionary`). Гейт по значению —
tests/test_3408_db_step_cancel_orphan.py: следующий шаг (голый `to_thread`, как
персист) не входит в сессию, пока сирота не закончил. Семантика проверена на
питоне прода (3.12): `wait_for` по-прежнему отдаёт TimeoutError, источник
деградирует в None.
Остальное из ревью:
- M2: комментарий у `_MAX_DEFERRED_REFRESH_TASKS` обещал за ОБА фоновых
источника, а верен только для Яндекса. Циан держит коннект весь фетч (до 25 с):
транзакцию открывают `_load_from_cache`/`load_session`, закрывает `db.commit()`
в конце (scraper_kit .../cian/valuation.py:163,176,595). Формулировка сужена,
остаток назван явно: функция общая со скраппером (cian_history_backfill.py:458),
где коммит в середине менял бы семантику батча, — нужен отдельный опт-ин путь.
На ПОТОЛОК пула остаток не влияет (коннект на задачу один независимо от того,
как долго держится), только на среднюю занятость.
- L1: `db.rollback()` после упавшего `_db_step` (estimator.py:1186) удалён —
откат уже сделан в потоке, а на loop'е это блокирующий вызов.
- L4: в core/db.py записано, что «пул >= суммы объявленных потолков» — ПОЛ, а не
гарантия: коннект держит и любая ручка с `Depends(get_db)`, а глобального
обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет (проверено:
единственный handler — RequestValidationError, core/http_errors.py:59).
- L3: гейт пула больше не читает `pool._timeout` и не молчит при переименовании
`_max_overflow` — публичный `pool.size()` + приватное поле за явным assert'ом.
`pool_timeout` из этого коммита УБРАН намеренно: это единственная правка, которая
меняет режим отказа с «медленно» на «быстро с ошибкой», и она едет во все сервисы
образа (backend, scraper, tgbot). Возвращается отдельным коммитом в конце ветки —
чтобы ветку можно было смержить без него или откатить одной командой.
Refs #3083, #3408
Только scripts/ + ci.yml: ни deploy.yml, ни deploy-tradein.yml по этим путям
не триггерятся. Мержить ПОСЛЕ частей 1 и 2a — гейт проверяет обе половины
и на main без них покраснеет.
Только caddy/sites/apps.caddy: по фильтру deploy.yml это caddy_only=true →
джоба deploy-caddy с 'caddy reload' (~1 с), без полного деплоя ПТИЦЫ.
Гейт и шаг ci.yml едут отдельной частью 2b, которая не триггерит ничего.
Повтор запроса включался на ЛЮБОМ ненулевом rc curl, хотя собственный
комментарий рядом называл сетевой класс (6/28/35/52/56). Протухший, чужой
или самоподписанный сертификат даёт rc=60 (замер: expired.badssl.com,
self-signed.badssl.com, wrong.host.badssl.com) — и измеренный регресс
периметра уезжал в колонку «периметр этой проверкой НЕ проверен», потратив
на детерминированный отказ три попытки и 6 c пауз. Ровно этот отказ и есть
предмет проверки 5b: без site-блока Caddy не выпускает сертификат.
Коды сетевого класса вынесены в NETWORK_RC рядом с комментарием, чтобы
описание и поведение не разъезжались; повтор делается только по ним. rc=7
(соединение отвергнуто) добавлен туда же — ответа при нём тоже нет.
curl_failed печатает rc в обеих ветках: строки RETRY при SMOKE_ATTEMPTS=1
нет вовсе, и «домена нет» (6) было не отличить от «сертификат протух» (60).
timeout-minutes 10 → 40: худший случай (прод не отвечает — мертвы все 43
проверки) = 43 × 53 c ≈ 38 мин, в 10 минут помещалось ~8 мёртвых проверок,
и job убивали ДО печати FAIL-строк и итога — в том самом сценарии, ради
которого правка и делалась.
`deals.rooms` — не комнатность, а синтетика из площади: import-rosreestr.sh пишет
тот же CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`. Прод-замер
2026-09-11: 321 559 из 321 560 сделок удовлетворяют rooms == area_bucket(area_m2),
max(rooms) = 4. Значит предикат `deals.rooms = <РЕАЛЬНЫЕ комнаты клиента>` — это
переодетый фильтр по площади, который противоречит area-полосе ±15% рядом с ним,
как только комнатность клиента нетипична для метража, и НИКОГДА не совпадает у
клиентов с 5+ комнатами.
Замер по 1177 реальным запросам (trade_in_estimates): ключ расходился с
area-бакетом у 359 (30.5%); коридор ДКП пуст у 46.2% из них против 8.2% у
совпадающих. По крупному жилью (≥85 м²): «3 комнаты» — 83.5% пустых коридоров,
«5 комнат» и «6 комнат» — 100%, «4 комнаты» — 5%. Т.е. блок «реальные сделки»
и клампы коридора (cap headline + radius-floor) молча выключались ровно у
крупных лотов.
Прогон тех же 1177 запросов через `_fetch_dkp_corridor` с обоими ключами:
непустых коридоров 809 → 895, пригодных для клампа (n≥10) 567 → 623 (+66, −10),
у 818 клиентов с совпадающей комнатностью выборка не меняется вовсе. Из 66
восстановленных коридоров 7 (5 из них ≥85 м²) обрезали бы headline вниз на
медианных −10.1% — то есть сейчас часть крупных лотов оценивается выше, чем
поддерживают реальные ДКП на той же улице.
Правка — одно и то же во всех четырёх местах, где сделки фильтруются под
клиента: `_fetch_dkp_corridor` (street + city-wide widen), `_fetch_deals`
(радиус) и витрина `/street-deals`.
Бэктест этим НЕ измеряется и в докстринг харнеса добавлена причина (каверза (e)):
у всех 5500 сделок обеих прод-фикстур rooms == area_bucket, т.е. харнес кормит
спайн синтетическим ключом и поэтому по построению не видит расхождения, которое
в проде есть у 30.5% запросов.
Refs #3256
Замер на проде 11.09 (изнутри хоста, тот же контейнер):
- одна оценка 0.44 с (повтор адреса) / 0.97 с (новый адрес), из них БД 252/458 мс;
- N=8 параллельных — все 200, heartbeat /health p95 5-7 мс, max 113-160 мс:
loop сегодня НЕ голодает, «добавить воркеров uvicorn» замером не подтверждается
(и умножило бы на N оба семафора, пять in-process лимитеров и пул);
- зато одна фоновая догрузка Яндекса держала коннект пула 8.5 с (лиз прокси
33.856 → запись 42.334), а таких задач разрешено 8 при пуле 15.
Правки:
- estimator `_db_step`: SELECT/UPSERT кэша источников уходят в `asyncio.to_thread`
и завершают транзакцию — коннект возвращается в пул ДО внешнего HTTP;
- core/db: max_overflow 10→15 (потолок 20 на процесс ≥ 4+4+8 объявленных
потолков одновременности) и pool_timeout 30→5 с (короче бюджета источника 8 с,
иначе занятый пул съедает и бюджет запроса, и поток to_thread).
Локальный замер ДО/ПОСЛЕ на тех же величинах: loop стоял 301 мс (0 тиков соседней
корутины) → 0.2 мс (23.5k тиков); ожидание коннекта соседом во время фетча —
таймаут пула → 0.1 мс.
Refs #3083, #3408
Публичный лендинг meraocenka.ru лежал 30–90 с на КАЖДОМ деплое (#3274).
Причина не в скорости подмены контейнера: она стоит полсекунды.
`docker compose up -d` со СПИСКОМ сервисов работает в две фазы — сначала
create (старый контейнер каждого сервиса останавливается и УДАЛЯЕТСЯ, иначе
занято container_name), потом start, в порядке зависимостей и с ожиданием
их условий. Между фазами старого фронта уже нет, а новый ещё не запущен.
Прод, 10.09, два деплоя подряд (docker inspect .Created/.StartedAt):
пачка сервисов: tradein-backend создан 15:01:40 → запущен 15:02:10 (30 с),
в логе Caddy три 503 на лендинге: 15:01:46/:52 и 15:02:06;
ОДИН сервис: tradein-frontend создан 16:42:17.5 → запущен 16:42:18.0
(0,5 с), 503 в логе нет ни одного.
Тот же двухфазный порядок воспроизведён на стенде (реальный образ фронта +
Caddy 2): соседи создаются сразу, стартуют через 41 с.
Поэтому frontend убран из общего `up -d $SERVICES` и пересоздаётся своей
командой после пачки: в его графе один сервис, create и start идут подряд.
Остаток ~0,5 с добирает ретрай подключения в Caddy — отдельным коммитом,
он мержится своим путём (caddy_only → graceful reload, без пересборки).
Проба для замера на живом деплое — scripts/probe-deploy-window.sh: считает
коды и САМУЮ ДЛИННУЮ серию не-200 в секундах, 000 отдельной строкой (его
даёт и отбой периметра, не только простой). Запускать НА хосте прода:
с внешнего адреса частая серия сама ловит 30–50 % 000.
Refs #3274
Прогон perimeter-smoke-mera на голове main (a659b187) покраснел на двух
последних проверках с кодом `000`. `000` у curl — это не «пришёл неверный
код», а «ответа не было вовсе»: периметр был цел, те же пути вручную
отдавали 503 и 405 от приложения (server: uvicorn, не заглушка Caddy).
Замер причины (внешний IP, /trade-in/api/v1/me): серия без пауз — 5 обрывов
из 12, с паузой 0.5 c — 6 из 12, с паузой 2 c — 0 из 8; с самого хоста прода
— 0 из 10. Приложение отвечает, частую серию запросов с одного адреса
отбивает вход. Смоук шлёт 44 запроса подряд и попадает под тот же эффект,
поэтому падают последние проверки списка.
Что сделано (ожидания и список путей НЕ тронуты):
- один общий curl_try, через который идут все запросы смоука. Повтор
только при «ответа не было» — признак берётся у самого curl (ненулевой
код возврата), ответ с «не тем» кодом для curl успешен и не повторяется
никогда, иначе ретрай маскировал бы настоящий регресс;
- отдельная формулировка FAIL (ответа нет) + итоговая строка «БЕЗ ОТВЕТА:
N проверок» — чтобы читатель красного лога не искал регресс периметра
там, где измерения не было;
- пауза 2 c между проверками. Наименьшая величина, у которой есть замер:
0.5 c измеренно не помогает, промежуточные значения не мерил никто;
- timeout-minutes воркфлоу 5 → 10: обычный прогон 89 c → 160 c (замер), а
неотвечающая проверка стоит до 3×15 c таймаута плюс паузы, и job убивали
бы до печати FAIL-строк.
Побочно тем же сторожем закрыты места, где обрыв врал диагнозом: в
check_redirect_location он читался как «Location не тот», а обрыв на
загрузке лэндинга — как «сам лэндинг сломан».
Проверка правки: прогон до (89 c, 43/43 PASS) и после (160 c, 43/43 PASS);
подставной curl, роняющий каждый нечётный запрос, — 44 повтора, итог
зелёный; фальсификация с подменённым ожиданием (/me → 200) и неверным URL
(/oferta-net-takogo) — обе строки красные, повторов ноль, выход 1.
Кэш `msk_raw.avito_geocode` создаётся только боевым прогоном (`geocode and not
dry_run`), а читается безусловно. На проде это роняло `--dry-run` первым же
запросом — UndefinedTable msk_raw.avito_geocode, то есть ломалась ровно та
репетиция, ради которой сухой прогон и существует.
Наличие отношения проверяется через `to_regclass`, а не ловится исключением: в
Postgres упавший оператор кладёт транзакцию целиком, и except потребовал бы
rollback посреди чужого батча.
Замер после правки (500 карточек, прод): отобрано 458, область 7, не разрешено
35 (7%), геокод-вызовов 338 на 500 карточек — дедупликация адреса внутри
страницы работает. Счётчики сходятся.
Заодно выяснилось, что дневная квота DaData на подсказки — 200 000, а не 10 000:
`stat/daily` на проде показывает suggestions remaining 200000 при нулевом
расходе. Весь корпус (21 565 различных адресов) проходит за один заход, дробить
на трое суток не нужно.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
rgid Москвы установлен эмпирически из разметки realty.yandex.ru и подтверждён
счётчиком офферов gate-API: 587795, вторичка 18 705 против 4 060 у ЕКБ (559132).
МО — 587654, Москва+МО — 741964, взят дефолтом по аналогии с region=-1 у Циана.
Адаптер повторяет контракт PlatformAdapter, но не тащит DOM-парсер: Яндекс
отдаёт SERP через gate-API, из кита берутся только чистые функции разбора
gate-payload. `YandexRealtyScraper` не создаётся вовсе — он существует ради
BrowserFetcher и пула прокси, а транспорт здесь прежний, вкладка Chrome
владельца по CDP. Chrome отдаёт gate-JSON текстом внутри <pre>, поэтому
экранирование разворачивается ДО json.loads, иначе описания приезжают битыми.
Два изменения общего кода, не косметические:
- `--target-count` стал платформо-зависимым (`PlatformAdapter.default_target`):
1500 у Авито и Циана без изменений, 500 у Яндекса. У Яндекса потолок
пагинации — 25 страниц по 20 офферов, то есть 500 на набор фильтров, втрое
ниже соседей; цель коридора выше потолка означала бы, что каждый коридор
штатно недобирается.
- `Sink.add` отсеивает `source_id` вне signed bigint: offerId Яндекса
19-значный, выход за диапазон уронил бы `\copy` всего батча, а не одну строку.
Пробный прогон 100 загрузок при задержке 8 с: ни одного признака блока,
350 карточек в `msk_raw.yandex_cards`, координаты и адрес у 100%. В отличие от
Авито, геокод Яндексу не нужен.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
У карточек Авито нет координат ни у одной из 50 335, а адрес — голая улица с
домом («Варшавское ш.,62к1»). Сбор шёл по `/moskva_i_mo/`, поэтому Москву от
области отделить было нечем: префиксный фильтр, работающий у Циана по округу,
здесь отбросил бы 100% строк. Наивный матч адресов к `houses(77)` даёт ровно 0
совпадений — дома лежат как «ЮАО, р-н Даниловский, проспект Андропова, 18».
Замер показал, что одного геокода мало: с констрейнтом «Москва + Московская»
дом находится у 92% адресов, но верхний кандидат DaData расходится с реальным
городом у 15% и почти всегда в пользу столицы. Недостающий сигнал лежал рядом и
бесплатно — слаг города в `source_url` (`avito.ru/moskva/...`), заполнен у 100%
карточек: 22 120 с `moskva`, остальное — подмосковные слаги.
Поэтому город берётся из слага и сужает констрейнт, а регион — из КЛАДР ответа,
не из слага: Новая Москва (Троицк, Щербинка, Коммунарка, Зеленоград) идёт своими
слагами, но это регион 77. Регион 77 пишется в `listings` сразу с координатами и
`geo_precision='house'`, поэтому строки попадают в radius-подбор аналогов без
ожидания `geocode_missing`. Регион 50 не пишется, а копится строкой в кэше
`msk_raw.avito_geocode` — до появления региона в реестре.
Кэш ключуется слагом и нормализованным адресом, отрицательные ответы тоже
кэшируются, так что повторный прогон внешний сервис не дёргает. `geocode_cache`
приложения не тронут — там другой ключ. `--allow-unfiltered` без `--geocode`
остаётся прежним аварийным режимом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
ПОЛОСЫ. deal_city_price_bands ключевались парой (region_code, city), а у всех
212 937 московских сделок city равен «Москва» — одна полоса 34221..718870 на весь
город при четырёхкратном разбросе цены между округами. Ключом стало выражение
COALESCE(NULLIF(raw_payload->>'src_city',''), city): округ заполнен у 198 600
сделок (93.27%), 197 различных значений. Выражение живёт в одном модуле
app/services/deal_city_key.py и используется и derivation, и всеми тремя
читающими местами — разъехавшийся ключ означал бы мёртвые строки таблицы.
Поиск полосы двухступенчатый: строка округа, затем строка города, затем
глобальные константы. Без второй ступени окно между деплоем и первым ночным
рефрешем уронило бы московские сделки на калибровку Екатеринбурга (пол 50 000
против 34 221). Замерено на проде: двухступенчатый поиск оставляет 208 677
сделок из 212 937, одноступенчатый — 207 594.
Потолок полосы стал региональным и собирается из именованных констант, общих у
SQL и питоновского двойника: GREATEST(800000, LEAST(p99.99, 6 x медиана)).
Регион 66 получает те же 800 000, регион 77 — 1 766 742, поэтому дорогие округа
(Пресненский p99 = 1 198 694) больше не срезаются потолком.
СБЕРИНДЕКС. Временная поправка замороженных ДКП-сделок была прибита к ряду
«Свердловская область» и применялась в том числе к московским сделкам. Замер:
средневзвешенный по 69 138 московским сделкам за 12 месяцев фактор равен 1.0313
по свердловскому ряду против 1.0917 по московскому — коридор занижен на 5.9%,
и он не advisory: участвует в clamp headline, radius-floor и Tier-C gate. Ряд
теперь резолвится по региону запроса, регион вне карты получает общероссийский
ряд, а не чужой региональный.
Монитор свежести следит за обоими рядами. Пропажа чужого ряда больше не
подавляет вердикт по ряду региона по умолчанию, ошибка драйвера откатывает
сессию, счётчики заполняются и в ветке раннего выхода.
РЕГИОН 66 БАЙТ-В-БАЙТ. src_city пуст у всех 108 623 его сделок, поэтому обе
ступени ключа совпадают; популяция derivation и все 383 строки полос не
изменились, потолок остался 800 000, ряд СберИндекса тот же.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Резолвер города в пробе покрытия знал только 9 центроидов Свердловской области,
поэтому любой московский адрес получал not_covered с пустым городом при живой
когорте рядом. Замер на проде: точка Тверской, 59 объявлений в радиусе 1 км,
статус not_covered, город пустой; контроль по Екатеринбургу — ok.
Москве заведена сетка из 67 центроидов, выведенная кластеризацией нашего же
корпуса (35 552 объявления Циан, ST_ClusterKMeans), плюс 32 отрицательные точки
Подмосковья: они участвуют в конкурсе ближайшего центроида, но порога не имеют,
поэтому граница с областью проходит по конкурсу центров, а не по окружности.
Радиус стал свойством центроида. У свердловских точек прежние 25 км байт-в-байт,
у московских 8 км: круги плотной сетки складываются, и общий 25-километровый
радиус протекал вглубь области — Наро-Фоминск 9.83 км до сетки, Кубинка 17.18,
Чехов 22.25, все резолвились как «Москва».
Порог Москвы жёлтый (12), не зелёный: 200 случайных московских адресов дают
медиану когорты 14 и долю с когортой не меньше 12 равную 0.57, против 37 и 0.865
у Екатеринбурга.
Остаточная цена — 48 московских объявлений из 35 552 (0.14%) в приграничной
полосе выигрываются подмосковным центром.
Тесты: 29 контрольных районов Москвы резолвятся в «Москва», 32 города области
дают «город не определён», резолв по Свердловской области сверен с прежней
реализацией на решётке из 851 узла — расхождений 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Сайт открыт для индексации 10.09.2026, но поисковику он до сих пор
представлялся девятью страницами без единого структурированного факта о
том, кто их публикует и что продаёт, а ссылка на него в мессенджере
разворачивалась голым текстом без картинки.
МИКРОРАЗМЕТКА. Конструкторы узлов вынесены в `_lib/schema.ts` — чистый
модуль без React, по образцу соседнего `_lib/analytics.ts`. Все значения
берутся из `content.ts`, ни одной строки не продублировано:
- `Organization` + `WebSite` одним `@graph` в layout поддерева, то есть на
всех девяти страницах сразу. Остальные узлы ссылаются на организацию
через `@id`, а не повторяют реквизиты у себя.
- `Service` с `Offer` на лэндинге.
- `FAQPage` в блоке возражений.
- `BreadcrumbList` на статьях, статье, документах и странице для бизнеса —
ровно по тем крошкам, что видны на экране.
- Существующий `Article` переведён на ссылку `publisher: { "@id": ... }`.
РАЗМЕТКА НЕ ОБЕЩАЕТ ТОГО, ЧЕГО НЕТ. Два места, где это стоило внимания:
- `Offer.availability` вычисляется из `PUBLIC_ESTIMATE_ENABLED`. Флаг сейчас
`false` (платёжного контура в коде нет), поэтому в разметку уходит
`PreOrder`, а не `InStock`: цена опубликована, купить нельзя, и врать об
этом поисковику нечего. Включится приём оплаты — значение сменится само.
- `FAQPage` строится из того же массива `ITEMS`, который рисует `<details>`,
а не из `FAQ` напрямую: в `ITEMS` часть вопросов заменена макетными
формулировками, и разметка от сырого `FAQ` разошлась бы с видимым текстом
молча. Выдуманных дат, рейтингов и отзывов не добавлено нигде.
КАРТИНКИ. Две штуки, и это не дубль: `og-mera.png` 1200×630 — превью ссылки,
`logo-mera.png` 512×512 — логотип организации в разметке, который поисковик
обрезает близко к квадрату и где баннер превратился бы в обрезок надписи.
Исходники обеих лежат рядом в `scripts/og/*.html` вместе с командой
перерисовки: картинка должна оставаться правимой, а не только
переоткрываемой в графическом редакторе.
Готовые PNG, а не `opengraph-image.tsx`: satori внутри `ImageResponse` рисует
только переданными ему байтами шрифта и кириллицу по умолчанию не покрывает,
плюс не видит ни CSS Modules, ни наших `--b2c-*`. Разбор — в шапке
`scripts/og/og-mera.html`.
`images` продублирован в `twitter`: Next не переносит их из `openGraph`, когда
метаданные заданы объектом, и карточка обещала бы крупное превью без картинки.
ПЕРИМЕТР. Обе картинки лежат в `public/` и раздаются Next'ом по
basePath-корню, куда rewrite `@meraPages` не достаёт, — отсюда два отдельных
`handle` по образцу robots.txt. Оба названы в `ROOT_HANDLES_ALLOWED`, иначе
двусторонний гейт периметра покраснел бы, и это правильно: корневой `handle`
— вторая дверь в тот же периметр.
Смоук проверяет не только 200, но и Content-Type: перепутанный rewrite отдаёт
200 с HTML, обходчик мессенджера молча его отбрасывает, и по логам приложения
этого не видно — запрос туда не доходит.
Проверено: tsc, eslint, 215 тестов mera-public, изоляция B2C-дерева, caddy
validate, bash -n смоука, прод-сборка с basePath и разбор отрендеренного
HTML — JSON-LD парсится, og:image и twitter:image на месте.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ
Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали end-to-end.
1. Импортёр msk_raw -> listings (app/tasks/msk_raw_import.py).
Переиспользует штатный save_listings из кита: писатель уже параметризован
регионом, свой не нужен. payload в msk_raw — сериализованный ScrapedLot
один в один, так что импорт сводится к сборке модели и вызову писателя.
Москва отбирается по префиксу административного округа в адресе, а не по
bbox. Причина: адрес Циан не содержит города, а границы региона 77
захватывают ближний пояс области. Замер по проду: с округом 35 552, все
внутри bbox 77; без округа внутри bbox 17 576 — это область. Отдельно
отсекаются 212 карточек с адресом «Екатеринбург (Cian)», артефакт парсера.
listing_segment ПЕРЕСЧИТЫВАЕТСЯ перед записью, а не копируется из payload.
Кит ставит novostroyki по одному наличию offer.newbuilding.id. Замер по
всем 60 464 карточкам: is_from_developer=true у НУЛЯ, false у 29 000,
отсутствует у 31 464. Застройщик не продаёт ни одной карточки корпуса.
В проде есть гвард (estimator.py): в аналоги идут строки только с
listing_segment IS NULL или 'vtorichka' — копирование метки как есть
выбросило бы 29 000 строк из подбора.
Авито импортируется только с явным --allow-unfiltered: в его адресе нет ни
города, ни округа, координат нет ни у одной из 50 335 карточек, отличить
область от Москвы нечем.
Прогон на проде: прочитано 60 464, записано 35 552, все с геометрией,
35 182 привязаны к дому, создано 10 960 домов. Повторный проход строки не
дублирует — idempotency на dedup_hash, проверено.
2. Подсказки адреса стали региональными (geocoder.suggest, api/v1/geocode).
Раньше suggest вообще не принимал регион: DaData звалась с жёстким
region='Свердловская', Nominatim — с viewbox 66-го и bounded=1. Московский
адрес давал ПУСТОЙ список молча, без ошибки; в коде это уже было описано
как известный баг. Механику по регионам переиспользовали из geocode(),
вторую не писали. Кадастровый тир для не-66 не зовётся: он на ЕКБ-данных.
3. Оценка перестала геокодировать Москву свердловским скоупом (estimator).
geocode() звалась без региона, то есть с дефолтом 66, и московский адрес
возвращал бы пустую оценку с причиной address_not_geocoded даже с рабочими
подсказками. Регион запроса определяется по координатам через реестр, затем
по city_hint, затем дефолт. Fast-path клиентских координат стал
региононезависимым: OBLAST66_BBOX и REGIONS[66].bbox_region совпадают
байт-в-байт, поэтому для 66 поведение прежнее, добавились координаты Москвы.
Регресс-нейтральность по Свердловской области — главный критерий всех трёх
кусков. Тесты: 1160 passed по затронутым областям.
Известные ограничения. Границы 77 захватывают ближний пояс области, Химки
резолвятся в Москву. У региона 77 нет ни одного тира обогащения, оценка
поедет на аналогах и сделках. Ценовая полоса по Москве одна на весь город.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
ДВА ПОСЛЕДСТВИЯ #3436, обнаруженные на прогоне против прода.
1. ДЕПЛОЙ МЕРЫ БЫЛ ЗАБЛОКИРОВАН. В #3436 политика конфиденциальности получила
раздел про cookie, то есть новую редакцию, и `PRIVACY_APPROVAL` во фронте
стал «№ 2 от 10 сентября 2026 г.». Бэкендовая `_CONSENT_POLICY_VERSION`
осталась на «2026-08-13», а между ними стоит гейт
`test_consent_text_frontend_sync.py` — он и упал. Job `test` в
deploy-tradein.yml падает → `deploy` пропускается по своему
`needs.test.result != 'failure'` → прод остался на старом образе фронта,
при том что Caddy обновился отдельным пайплайном. Внешне это выглядело как
«задеплоилось наполовину»: UTM на редиректе со слэшем починился, а noindex
и robots.txt — нет.
Гейт сработал ровно как задуман: версия согласия обязана указывать на ту
редакцию документа, которую человек реально видел, иначе снимок согласия
в trade_in_leads.consent_policy_version подписан не тем документом. Правим
версию, а не тест. Согласия, собранные до 10.09, остаются с "2026-08-13" —
в этом и смысл хранить версию per-row.
2. СМОУК ЖДАЛ 404 ТАМ, ГДЕ ПРОД ОТВЕЧАЕТ 401. Проверка «карта сайта МЕРЫ не
просачивается через B2B-домен» ожидала 404 от allowlist'а site-блока, но
корень gendsgn.ru закрыт пилотным basic_auth, и гейт отвечает 401 РАНЬШЕ,
чем запрос доходит до allowlist'а. Проверка была написана без прогона
против прода — это честно отмечено в её же комментарии — и упала на первом
же запуске.
Заведён `check_any`: PASS на любом из перечисленных кодов. Здесь допустимы
401 и 404 — оба означают проверяемое («наружу этого адреса нет»), а какой
рубеж ответил первым, к предмету проверки отношения не имеет. Жёсткое
ожидание к тому же сломалось бы при снятии пилотного гейта. Красная строка
осталась там, где ей место: 200 означал бы реальную течь.
Проверено: `pytest tests/test_consent_text_frontend_sync.py` — 6 passed;
полный сьют бэкенда МЕРЫ локально 5737 passed; `bash -n` на смоуке чист;
`check_any` прогнан против живого gendsgn.ru — PASS на фактическом 401.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ
ЗАЧЕМ. Статьи МЕРЫ публикуются с UTM-метками, но посмотреть, приходил ли по
ним кто-нибудь, было физически нечем: веб-аналитики на публичном контуре не
было вовсе. Заодно вскрылось, что «толкнуть в выдаче» тоже нельзя — всё
дерево mera-public отдавало `robots: noindex, nofollow`.
СЧЁТЧИКИ. Яндекс.Метрика и GA4 подключаются ТОЛЬКО в `mera-public/layout.tsx`
и никогда в корневом `app/layout.tsx` — иначе счётчик уехал бы в закрытый
контур (/v2, /admin, /scrapers, /history), где анонимных посетителей нет, а
приватные маршруты сотрудников есть. Идентификаторы приходят build-time
(`NEXT_PUBLIC_YM_ID` / `NEXT_PUBLIC_GA_ID`) — канон Dockerfile'а этого
проекта: Next инлайнит NEXT_PUBLIC_* на сборке, runtime env их не подхватит.
Пустое значение = тег не рендерится вовсе, никаких `ym(undefined)`. Оба
build-arg'а прописаны в ОБОИХ блоках CI, включая retry-сборку без кеша.
Вебвизор выключен намеренно. Он пишет ввод в поля, а на `/estimate` человек
вводит адрес своей квартиры; раздел 9 политики этого не раскрывает. Включать
следует одним заходом с правкой политики и маскировкой полей — в коде рядом
записано, что именно понадобится.
ЦЕЛИ ВОРОНКИ. Десять целей: клик по CTA, начало ввода адреса, адрес выбран,
результат с разбивкой по вердикту (ok/thin/none), ошибка расчёта, ошибка
валидации, отказ подсказок, показ платного тизера. Кнопок «Проверить
квартиру» восемь штук в разных компонентах, все — обычные `<a>` через
PublicLink, поэтому вместо восьми копий onClick один делегированный
слушатель на document: девятая кнопка подключится сама. Цель «оплата
успешна» НЕ заведена — вызова checkout во фронте нет вовсе, PAYMENTS_ENABLED
выключен, страницы возврата не существует; вешать её пока не на что.
ИНДЕКСАЦИЯ. Снят noindex со всех публичных страниц, добавлены `app/robots.ts`
и `app/mera-public/sitemap.ts`, metadataBase, canonical на КОРОТКИЕ адреса,
openGraph и JSON-LD Article на главной статье. robots.txt и sitemap.xml
разведены по двум разным handle в Caddy не от хорошей жизни: у Next
robots.txt — конвенция корня app/, а sitemap живёт в сегменте маршрута, и
формы путей не совпадают.
152-ФЗ. Раздел 9 «Файлы cookie и веб-аналитика» в политике (обработчики
названы поимённо — этого требует ч. 3 ст. 6) + уведомляющий, не блокирующий
баннер. Гейт «названий площадок в публичной копии быть не должно» получил
узкое исключение ровно на аналитические словосочетания в политике; голое
«Яндекс» как площадка остаётся запрещённым и там.
ПОПУТНЫЙ БАГ (замер на живом проде 10.09.2026). `meraocenka.ru/articles/`
с UTM-метками отдавал 301 на адрес БЕЗ query — матчер @meraShortSlash
собирал цель из regex-захвата пути и терял параметры. Код ответа при этом
оставался 301, поэтому смоук проблему не видел. Мессенджеры и
автолинкификаторы дописывают слэш сами, то есть атрибуция терялась именно на
трафике по опубликованной ссылке. Починено тем же приёмом, что у соседних
матчеров; в смоук добавлена проверка буквального Location.
ГЕЙТЫ. `isPublicPath` и периметр-тест узнали про новые машинные адреса;
noindex-гейт развёрнут (падает, если флаг вернулся) и расширен на строковую
форму `robots: "noindex"`; заведена проверка, что корневых `handle` в
site-блоке не появляется без объявления — раньше эту дверь гейт не видел.
Проверено: tsc и eslint чисто, `npm run build` проходит, robots.txt и
sitemap.xml отдаются по нужным адресам, при пустых ID в HTML нет ни одного
обращения к mc.yandex.ru и googletagmanager, `caddy validate` валиден,
изоляция mera-public от B2B не нарушена. Два теста LoginPage падают и на
нетронутом дереве — не наши.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ
Сборщик получает ключ --platform {avito,cian}: платформо-зависимые куски
(URL коридора, счётчик, парс, потолок пагинации, целевая таблица) вынесены
в PlatformAdapter, общая часть — бисекция по цене, guard на блок, накопитель
и заливка — одна на обе площадки.
Скоуп Циан проверен живыми запросами 10.09, а не взят из документации:
- region=1 и region=4593 двумя параметрами НЕ объединяются, сервер берёт
последний. Прежний базовый URL собрал бы одну Московскую область и молча
потерял Москву целиком (92 817 объявлений). Правильный скоуп — region=-1.
- object_type[0]=1 обязателен: без него счётчик считает новостройки, которые
дальше не сохраняются, и расходится с числом карточек вдвое.
- Итоговый скоуп: 62 548 объявлений вторички по Москве и МО.
- Потолок пагинации 54 страницы подтверждён: страница 55 пуста.
Фильтра новостроек в адаптере нет, и это сознательное отличие от кита. Кит
считает новостройкой всё, у чего есть offer.newbuilding.id (serp.py:401-402),
потому что для ЕКБ выдача бралась без object_type. На московской странице из
28 карточек 16 имеют этот блок, и у всех шестнадцати isFromBuilder=false,
isFromLeadFactory=false — это вторичка в ЖК, а не лоты застройщика. С китовым
фильтром прогон терял бы 57 % корпуса безвозвратно. msk_raw — сырьё, payload
несёт listing_segment целиком, сегмент отделяется на импорте в listings.
Детект блока Циан. Капча приходит как HTTP 200 с обычной на вид страницей:
поймана живьём, 40 КБ, title «Captcha - база объявлений ЦИАН», без
window._cianConfig. По статусу её не отличить, поэтому маркер ищется в первых
4 КБ (в нормальной выдаче на 2,6 МБ там ни одного вхождения). Вторая сеть в
parse_page: счётчик None при нуле сырых карточек — тоже стоп. Без этого блок
засчитывался бы как пустая страница, коридор уходил в done, а --resume его
уже не перебрал бы.
Гвард empty_page считает карточки ДО фильтра: иначе штатный ноль после
фильтрации был бы неотличим от блока. У Авито атрибута нет, дефолт — длина
итогового списка, поведение прежнее.
Заливка. Целевая таблица берётся из адаптера, staging создаётся с
INCLUDING IDENTITY (без него identity-колонка не переносится, а NOT NULL
переносится всегда, и \copy падал бы на каждом батче). Наличие таблицы
проверяется на старте одним SELECT to_regclass — иначе прогон умирал бы
на первой заливке, после часов планирования.
Миграция 299 заводит cian_cards, domclick_cards, yandex_cards и три вью
по образцу avito_cards/avito_latest. id — bigserial, а не IDENTITY, ровно
по причине выше.
Заодно: _wt-mskcol/ выведен из индекса и закрыт в .gitignore. Копия-worktree
попала в репозиторий 09.09, и три фикса ушли в дубликат мимо канонического
collect.py — дефект нашёлся только при сверке размера страницы.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Прогон длится часами, и ssh рвётся: живьём поймано Connection reset by peer
(ssh 255) прямо посреди заливки — умирал весь прогон, несброшенный батч
(до 1000 карточек) терялся, resume перечитывал страницы заново.
Ретрай безопасен: SQL идемпотентен целиком — batch через ON CONFLICT DO
NOTHING, карточки через ON CONFLICT (source_id,batch_id,kind) DO NOTHING.
Ретраится ТОЛЬКО транспорт (код 255); ошибка самого psql под ON_ERROR_STOP
поднимается сразу — это дефект данных или SQL, повтор его не лечит.
PAGE_SIZE=60 был взят из потолка 30x60=1800, а не измерен. Реальная выдача
Москва+МО отдаёт 50 на страницу, поэтому planned_pages = ceil(count/60)
не запрашивал последние ~17% каждого коридора: по закрытым коридорам счётчик
Авито обещал 34 753, собрано 28 352.
Это ровно тот молчаливый недобор, который читается как «покрыто всё»: коридор
помечался done, дойдя до расчётной страницы, хотя выдача не кончилась. С 50
потолок пагинации даёт 1500 на запрос — совпадает с дефолтным --target-count,
так что коридоры бисекции по-прежнему добираются целиком.
Рендерер Chrome копит память по всем навигациям вкладки, а московский проход —
под тысячу страниц в одной. Живьём поймано: вкладка падает с «Опаньки… Код
ошибки: Out of Memory» при 34 ГБ свободных в системе — упирается рендерер, а не
машина. Свежая вкладка стоит одну навигацию и обнуляет счёт.
Закрывается только своя вкладка; контекст и вкладки владельца не трогаются.
Геокодер был жёстко привязан к Свердловской области: viewbox 66 +
bounded=1, accept только при state ~ 'свердловск' и точке в bbox 66,
known_city_hint знал лишь города области → для 212 937 московских сделок
(address 'Москва, <улица>') геокод давал None либо ложный хит по
одноимённой улице области, а cache-ключ без города смешивал регионы.
Теперь регион приходит от вызывающего (deals.region_code): viewbox и
bbox из REGIONS[code], state-маркер per region ('свердловск'/'москва'),
ЕКБ-тиры (geoportal/cadastral/local houses) только при 66, city-хинт
через словарь региона → cache-ключ '|city=москва'. Дефолт 66 везде —
для существующих вызовов поведение байт-идентично (ревью двумя
линзами). Побочно: geocode-missing по умолчанию больше не берёт
listings с region_code NULL (16 930 неактивных чужих городов, которые
и раньше геокодились впустую).