1710 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| be9aa2f907 |
Гейт на ВСЕ 34 проводки + запрет вложенных бюджетов (ревью #3460)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 5m14s
Сценарный тест ловил одну проводку из 34 — ту, через которую сам и шёл (`geocoder._cache_get`). Мутационный прогон ревьюера: возврат голого `asyncio.to_thread` в 5 из 6 других мест тест НЕ краснит, то есть регресс «кто-то вернул вызов в голый вид» прошёл бы мимо CI в 33 случаях из 34. `test_no_bare_to_thread_over_request_session` читает исходники geocoder и estimator (через `module.__file__`, не по относительному пути — он зависел бы от cwd прогона) и требует нуля живых `asyncio.to_thread(`. Оба модуля сейчас на нуле, поэтому гейт без списка исключений. Фальсификация — голый `to_thread` у `_fetch_anchor_comps` (estimator:4973, сценарным тестом не покрыт): гейт краснеет с номером строки. Второе: защита `run_db_thread` одноразовая — `except asyncio.CancelledError` ловит ОДНУ отмену, вторая вылетает из самого `asyncio.wait([step])`, и поток остаётся сиротой. Живых путей нет (`_with_budget` нигде не вложен, Starlette не отменяет задачу на дисконнекте, uvicorn стартует без `--timeout-graceful-shutdown`), поэтому кода не трогаю — фиксирую инвариант «не вкладывать бюджеты» в докстринге `_with_budget`, чтобы вложение не завезли как безобидное. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| c251c02f1e |
Отмена по бюджету больше не оставляет сироту в сессии запроса (#3449)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 5m24s
`asyncio.to_thread` отменить нельзя: по истечении бюджета (`_with_budget` = `asyncio.wait_for`, у геокодера 12 с) снимается только ожидание со стороны loop'а — поток продолжает работать с ТОЙ ЖЕ `Session`, что и весь запрос. Вызывающий тем временем идёт дальше: следующий источник, `_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной `Session` дают «another operation is in progress» / InvalidRequestError на СЛЕДУЮЩЕМ шаге. У источников эту ошибку глушит `except` вокруг вызова, у персиста оценки не глушит никто — 500 и потерянная оценка клиента. `app/core/db.py: run_db_thread` — ТОЛЬКО защита от сироты: `ensure_future` + `shield`, на отмене дождаться потока (`asyncio.wait`), прочитать `step.exception()` (иначе asyncio печатает «Task exception was never retrieved» без контекста) и пробросить отмену. Commit/rollback туда НЕ вынесены: посреди геокодинга commit зафиксировал бы частичное состояние оценки. `estimator._db_step` переписан поверх и добавляет свои commit/rollback сам — его поведение не меняется, гейт tests/test_3408_db_step_cancel_orphan.py остаётся зелёным. Заменено 34 вызова, работающих по сессии запроса: 12 в geocoder.py (кэш-чтение и записи, геопортал, кадастр, houses, reverse, suggest), 19 в estimator.py (в т.ч. `_backfill_house_fias`, `_save_yandex_history_items`, `_fetch_anchor_comps`, `_price_from_inputs` с db-резолверами, персист оценки, `_fetch_price_trend`, `_is_premium_building`), 2 в api/v1/geocode.py, 1 в api/v1/privacy_admin.py. Не тронуты вызовы со СВОЕЙ сессией: `user_events.schedule_event` (внутри `record_event` свой `SessionLocal`) и `sber_index` (сессия задачи планировщика, отменять её некому). Гейт по значению — tests/test_3449_geocoder_cancel_orphan.py: отмена по бюджету во время шага БД геокодера, следом ГОЛЫЙ `to_thread(db.execute, ...)` (образец персиста); проверяется, что он не вошёл в сессию, пока сирота ещё в ней. На исходном коде тест краснеет: conflicts == 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
|
10ffa93a1c |
fix(support): доставленное сообщение не теряется при сбое БД, отказы Telegram расходуют бюджет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 1m11s
CI Trade-In / backend-tests (pull_request) Successful in 5m26s
Два последних дефекта из разбора телеграм-стека, оба в ручках веб-поддержки. Предыдущие три PR (#3456, #3457, #3458) чинили клиент и мост; эти — сами ручки. ## Сбой БД уже ПОСЛЕ доставки в топик Порядок «сначала Telegram, потом БД» осознанный, но блок записи не был обёрнут ничем, в отличие от шага отправки. `SQLAlchemyError` там означал: сообщение оператору доставлено, а клиент получил 500. Дальше по цепочке — пользователь шлёт повторно, в топике дубль, а на осиротевшее зеркало оператор отвечает в пустоту, потому что треда в БД нет и мост на реплай пишет только WARNING. Обе ручки теперь ловят `SQLAlchemyError` вокруг блока БД, тихо откатывают сессию, предупреждают оператора реплаем к доставленному зеркалу и отдают клиенту успех. Успех, а не отказ: доставка правда состоялась, и отказ спровоцировал бы ровно тот дубль, которого избегаем. Анонимная ветка на этом пути дополнительно ставит куку, хотя штатно ставит её только на успехе: треда нет, но идентичность посетителя обязана пережить сбой, иначе следующее сообщение заведёт второй тред. ## Успеха мало — клиент должен об этом узнать Первая версия правки отдавала успех молча, и это было неотличимо от тишины. Фронт выбрасывает тело POST и рендерит переписку только из GET, а сообщения там нет: поле ввода очищается, в списке пусто, баннера нет. Пользователь решает, что не отправилось, и шлёт снова — тот самый дубль. Нашло adversarial-ревью, и это подтверждено чтением `useSupportChat.ts` и `SupportChatPanel.tsx`. Поэтому `SupportMessageOut` получил поле `persisted` со значением `True` по умолчанию — все существующие пути и `GET /support/messages` отдают его без изменений. На пути деградации приходит `False`, и панель показывает рядом с композером предупреждение: сообщение получено оператором, но в переписке его не будет, отправлять ещё раз не нужно. Баннер гаснет на следующей нормально записанной отправке. Анонимный виджет рендерит ту же панель и получает это поведение автоматически. Текст предупреждения оператору тоже переписан: он больше не рассчитывает на то, что клиент напишет снова, и прямо говорит, что ответить через бота не получится. ## Рейт-лимит переставал считаться при недоступном Telegram `retry_after()` — это peek, а `record()` звался только на успехе. Верно для «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram лежит, лимита нет вообще, и каждый повтор стоит до четырёх попыток к api.telegram.org, не расходуя ни один бюджет. Двух-трёх вкладок с авто-повтором хватает, чтобы выесть лимиты группы ровно тогда, когда канал и так еле жив. Добавлен отдельный счётчик отказов на тех же ключах: пять подряд в окне тридцати секунд включают cooldown, и ручка отвечает 429 не доходя до Telegram. Пять подряд на живом канале практически недостижимы, а `reset()` на успехе стирает историю — считаем именно подряд. Тридцать секунд заведомо короче реальной недоступности, так что после восстановления пользователя не наказывают. Основной «успешный» бюджет и non-destructive peek не тронуты. `SlidingWindowLimiter.reset(key)` добавлен аддитивно, с оговоркой в докстринге, что лимитерам-бюджетам он противопоказан. Барьер рассчитан на несколько вкладок с авто-повтором, а не на одиночного последовательного клиента: один отказавший запрос сам занимает до двадцати трёх секунд, и пять таких в окно не укладываются. Это принято сознательно — ловить одиночку значило бы наказывать обычного пользователя за чужую аварию. ## Тесты Отказ БД в обеих ручках: клиент получает успех с `persisted=False`, оператору уходит предупреждение, текст обращения в него не попадает, 500 не возникает. Отказ самого уведомления ручку не роняет. Серия отказов включает cooldown, и до Telegram запрос не доходит. Окончание окна cooldown снимает. Успешный путь и существующий рейт-лимит не изменились. Бэкенд: 117 passed, ruff чистый. Фронт: type-check чистый, lint без новых замечаний. |
||
|
|
1fa65eba6b |
fix(tg): связь с Telegram не встаёт колом, ответ оператора не теряется
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m19s
Замер прода за сутки 12.09.2026: 576 строк `network error` в логе `tradein-tgbot` и 7 полных исчерпаний бюджета ретраев, после которых падала итерация poll loop. Три причины, все подтверждены на коде и в рантайме. ## Ответ оператора мог пропасть навсегда `process_update` заканчивался безусловным `finally: save_offset(update_id)`. Замысел верный — «ядовитый» апдейт не должен блокировать поток, — но он не отличал неисправимый апдейт от транзиентного сетевого отказа. Оператор отвечает клиенту в топике, `copy_message` падает по сети, `TelegramNetworkError` улетает в общий `except Exception`, offset сдвигается. Telegram этот апдейт больше не отдаст, `record_message` не выполнился, оператор уверен, что ответил. Следа нет нигде, кроме строчки в логе. Теперь `process_update` возвращает `bool`. На `TelegramNetworkError` делается `rollback()`, offset НЕ сохраняется, возвращается `False`, и `run_poll_loop` прерывает разбор пачки — offset у Telegram единая «высшая отметка», подтверждение любого следующего апдейта неявно подтвердило бы и этот. Остаток пачки Telegram отдаст заново. Переигрывания ограничены сверху `_MAX_NETWORK_REPLAYS = 3`: без потолка «вечно недоставляемый» апдейт заклинил бы очередь навсегда, а это хуже потери одного сообщения. На потолке offset всё-таки двигается, но с `logger.error` и с `chat_id`/`message_id`, по которым человек найдёт ответ в топике и перешлёт руками. Текст переписки в лог по-прежнему не идёт. Дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев, при этом запрос мог дойти до Telegram, а ответ потеряться. Переигрывание тогда доставит сообщение второй раз. Это осознанный at-least-once компромисс — дубль видят и клиент, и оператор, а тихая потеря не видна никому. Полная идемпотентность по паре (update_id, target_chat_id) потребовала бы новой персистентной таблицы ради редкого случая; вместо неё число дублей жёстко ограничено сверху. Ветка `except TelegramApiError` с разбором `error_code == 403` («бот заблокирован») не тронута — там повтор действительно ничего не изменит. ## Таймаут задавался скаляром, поэтому connect ждал сорок секунд `httpx.AsyncClient(timeout=effective_timeout)` разворачивается в connect=read=write=pool. Для `getUpdates` бюджет ответа 40 секунд (30 держит Telegram плюс запас), и те же 40 секунд уходили на установку соединения — при живом connect в 0.036 секунды. Худший цикл: четыре попытки по 40 секунд плюс backoff, около трёх минут, в течение которых бот не видит ответов оператора. В логе это ровно те разрывы: 06:40:10, 06:42:22, 06:43:35. Теперь `httpx.Timeout(connect=5, read=<бюджет вызывающего>, write=10, pool=5)`, значения в именованных константах. Запас `+10s` у `get_updates` относится к read, докстринг поправлен. ## Клиент создавался заново на каждую попытку `httpx.AsyncClient` стоял ВНУТРИ цикла ретраев — keep-alive не было вовсе: полный TCP+TLS-хендшейк на каждый запрос и на каждый повтор, и заново кидался кубик «встанет ли коннект». Для long-polling это была основная статья сетевых отказов. Плюс три HTTP-ручки создавали `TelegramClient` на каждый входящий запрос. Теперь один ленивый переиспользуемый `AsyncClient` на экземпляр, с `aclose()` и `async with`. Общий клиент приложения живёт в новом `app/services/tgbot/shared.py`, создаётся и закрывается в lifespan; воркер бота держит свой на время поллинга. `keepalive_expiry` задан явно: дефолт httpx — 5 секунд, и с ним пул не давал бы ничего там, где нужнее всего. Poll loop переиспользует соединение и так, а вот веб-поддержка шлёт раз в минуты и за 5 секунд теряла бы его каждый раз. Плата за длинный keep-alive — шанс взять из пула закрытое той стороной соединение; httpx отдаёт это как `RemoteProtocolError`, который ретраится с #3457. ## Уведомления оператору шли с воркерным бюджетом внутри poll loop Обе отправки в топик («бот заблокирован», «веб-чат не поддерживает медиа») звались без своего бюджета, то есть с дефолтом в 5 ретраев и backoff до 30 секунд. Одна такая отправка стопорила весь цикл на минуты, а её отказ решал судьбу апдейта. Вынесены в `_notify_topic` с узким бюджетом и собственным `except`: провал вторичного действия больше не отменяет основную ветку. ## Тесты `tests/services/tgbot/test_shared.py` — новый, на жизненный цикл общего клиента. В `test_bridge.py` — сетевой отказ оставляет offset нетронутым и апдейт переигрывается, потолок разблокирует поток, отказ уведомления не отменяет основную ветку, прежнее поведение на 403 не изменилось. В `test_client.py` — раздельные таймауты доезжают до httpx per-request, два вызова используют один `AsyncClient`, `aclose()` его закрывает. Прогон по затронутым файлам: 127 passed. Ruff check и format чистые. Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика, и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно. |
||
|
|
087c48fef5 |
fix(tg): ретраим весь TransportError, остальной RequestError → 502 без ретраев
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m10s
Follow-up к #3456. Тот PR научил три HTTP-ручки ловить общий `TelegramError` и отдавать 502, но закрыл дыру не до конца: клиент по-прежнему выпускал наружу сырой httpx. Ретраящийся `except` перехватывал узкий кортеж `(httpx.TimeoutException, httpx.NetworkError)`, а `RemoteProtocolError`, `ProxyError`, `LocalProtocolError` и `UnsupportedProtocol` — не наследники `NetworkError`, а сёстры по `TransportError`. Проверено запуском на httpx 0.28.1, не по памяти. Практическое следствие — ровно тот отказ, который #3456 и чинил. `RemoteProtocolError` («Server disconnected without sending a response») для api.telegram.org из РФ — бытовой ответ, а не экзотика. Он вылетал из `_request` сырым, проходил мимо `except TelegramError` в glitchtip.py:227 и support.py:233 и :424, и FastAPI снова отдавал 500. Глобального обработчика, который поймал бы его выше, нет: в `core/http_errors.py` зарегистрирован только `RequestValidationError`. Вдобавок такой отказ не ретраился ни разу — вылетал с первой попытки, без backoff и без строки лога о сетевом сбое, так что в проде отличить его от исчерпания бюджета было нечем. Теперь два `except`, и вместе они покрывают всё дерево отказов запроса. Ретраящийся расширен до `httpx.TransportError` — тело не тронуто, те же reason, backoff, лог и `TelegramNetworkError` из #3156. Ниже страховочный `httpx.RequestError` без ретраев: сегодня это `DecodingError`, завтра — всё, что httpx заведёт под `RequestError`. Порядок значим — `TransportError` наследник `RequestError` и обязан стоять выше, иначе сетевые отказы перестали бы ретраиться. Повторов у страховочного нет намеренно: испорченный ответ и кривую конфигурацию повтор не лечит, а пять попыток с backoff подвесили бы интерактивную ручку почти на минуту впустую. Расширение ретраев на `RemoteProtocolError` наследует уже принятый в этом клиенте риск at-least-once: запрос мог дойти до Telegram, а ответ потеряться. Риск тот же, что у давно ретраящегося `ReadTimeout`, политика не меняется. Прецедент лова именно `TransportError` в этом же репозитории — `app/services/payments/tbank_client.py:136`. Не тронуто: ручки (они уже ловят предок), `bridge.py` (`except TelegramApiError` там намеренный — разбор 403 «бот заблокирован»), `_extract_retry_after`, обработка 429/5xx, потолки backoff. Тесты: прежний тест «наружу свой тип» параметризован по `ConnectTimeout`, `RemoteProtocolError`, `ProxyError`, `DecodingError` с ожидаемым числом попыток; новый тест фиксирует разницу бюджета — обрыв протокола ретраится, битый ответ нет. Прогон по четырём затронутым файлам: 80 passed. |
||
|
|
46326ba96e |
fix(tg): недоступный Telegram отдаёт 502, а не 500
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m8s
Прод 11.09.2026, 01:35 и 01:38 MSK — два 500 на glitchtip-webhook. Причина не в вебхуке: `TelegramClient._request` после исчерпания сетевых ретраев делал голый `raise`, наружу летел `httpx.ConnectTimeout`. Все три HTTP-ручки ловят `TelegramApiError` — сырой httpx пролетал мимо, и FastAPI отдавал 500 вместо задуманного 502. Отказ площадки и её недоступность для вызывающего неразличимы: переслать не смогли и там, и там. Клиент больше не выпускает наружу чужой тип. Появился общий предок `TelegramError`, под ним прежний `TelegramApiError` (ответили `ok: false`) и новый `TelegramNetworkError` (не ответили вовсе). Раздельно, а не наследником, потому что у сетевого отказа нет ни `error_code`, ни `description` — брать их неоткуда, а `bridge` по `error_code == 403` разбирает «бот заблокирован» и недоступность в этот разбор попадать не должна. Причина сохраняется в `__cause__`: в GlitchTip по-прежнему видно, таймаут это соединения или сброс TLS (#3156). Три ручки — вебхук GlitchTip и обе ручки поддержки, авторизованная и анонимная — ловят предок. Поведение воркеров не менялось: poll loop в `bridge` и так ловит `Exception`, бюджеты ретраев те же. Тесты: два в клиенте (свой тип наружу, причина не потеряна, это НЕ `TelegramApiError`), три на ручках (502 на недоступности, ничего не персистится, анонимной куки не выдаём). Четыре теста бюджета ретраев ждали `httpx.ConnectTimeout` — ждут новый тип, проверяемые паузы прежние. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |
||
|
|
1bcc922e80 |
fix(msk-collector): сетевой обрыв на машине больше не убивает многочасовой проход
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m6s
Полный проход Яндекса по Москве умер 12.09 на восьмом часу: `Page.goto: net::ERR_NETWORK_CHANGED`. Площадка была ни при чём — сразу после и realty.yandex.ru, и git.gendsgn.ru отвечали 200, а в ту же минуту по DNS отвалился и MCP-сервер ошибок. Моргнула сеть на самой машине. Ретрай в `_goto` ловил только `PlaywrightTimeoutError`, поэтому обычная сетевая ошибка уходила наверх и роняла прогон целиком. Сетевые отказы Chromium вынесены в `_TRANSIENT_NET_ERRORS` и считаются СВОИМ счётчиком: пять попыток с нарастающим ожиданием 15/30/45/60/120 с. Бюджет попыток по таймауту при этом не тратится — обрыв сети длится минуты, а таймаут навигации это совсем другой симптом. Исчерпали — стоп с причиной `nav_network`, прогон продолжается тем же batch-id с --resume. Всё, что не в списке, по-прежнему поднимается: тихий отказ площадки выглядит ровно так же, и молча проглоченный он превращается в пустой прогон. Тестов у сборщика не было вообще, при том что он уже дважды ронял многочасовой прогон на ошибке, которая отказом площадки не была. Заведён первый файл: он подгружает скрипт по пути (нет на месте — skip, не красный) и караулит границу «наше или ихнее» — распознавание сетевых ошибок, переживание короткого обрыва, остановка при длинном с правильной причиной, проброс незнакомой ошибки и неизменность прежнего пути по таймауту. Полный сьют бэкенда — 5902 passed, 37 skipped; ruff чист. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |
||
|
|
ce9c45c3c2 |
feat(msk): импортёр знает Яндекс — город из адреса, ноль внешних вызовов
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 5m9s
Сбор Яндекса по Москве уже идёт, а лить его было нечем: в 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 |
||
|
|
37494dd74a |
fix(msk): тест полос по округам разъехался с main — у _fetch_deals нет rooms
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
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 5m7s
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 |
||
| a99a9b870c |
Merge pull request 'Москва: пред-геокод Авито, Яндекс третьей площадкой, продукт отвечает по региону 77' (#3440) from feat/msk-collector-cian into main
Some checks failed
Deploy Trade-In / test (push) Failing after 4m18s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
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 / deploy (push) Has been skipped
|
|||
|
|
09f4f17ef9 |
fix(msk): lock_timeout в миграции 299 — гейт блокирующего DDL
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
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 5m23s
Джоба `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 |
||
| c4315b3dfb |
Merge pull request 'fix(mera/estimate): убран предикат d.rooms в коридоре ДКП — он был вторым фильтром по площади (#3256)' (#3445) from fix/3256-asking-to-sold-buckets into main
Some checks failed
Deploy Trade-In / build-browser (push) Successful in 47s
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 2s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Failing after 4m18s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
|
|||
|
|
86cba37218 |
docs(#3256): докстринг коридора без «та же rooms»; якорь называет оставшегося потребителя (TVF 211)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-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 Trade-In / backend-tests (pull_request) Successful in 5m7s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
|
||
| 6df6f92a2b |
test(estimator): полоса площади — единственный фильтр похожести, а не просто «есть»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m6s
`test_dkp_corridor_keeps_full_area_band` проверял только присутствие полосы ±15% в bind-параметрах — она есть и на origin/main, и на варианте с бакет-ключом, поэтому тест был зелёным по построению и ничего не охранял (мутационная проверка: при восстановлении предиката он оставался зелёным, пока остальные 4 краснели). Утверждение усилено до «полоса единственная»: тест дополнительно требует отсутствия предиката по rooms рядом с ней. На origin/main фильтров по площади ДВА (полоса и бакет через d.rooms), итоговое окно — их пересечение, поэтому теперь тест краснеет значением вместе с остальными. Refs #3256 |
|||
|
|
7b84e4d2a5 |
fix(msk): миграция 299 заводит схему msk_raw сама, а не полагается на прод
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m12s
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 |
||
| 16d99e0f1a |
fix(estimator): убрать предикат по deals.rooms, а не подставлять в него area-бакет
Разворот предыдущего коммита ветки ( |
|||
| ae6d28d5e2 |
fix(mera): pool_timeout 30→5 с — отдельным коммитом, с триггером отката
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m4s
Единственная правка ветки, которая меняет РЕЖИМ ОТКАЗА при исчерпании пула: было «медленно» (ждём коннект до 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 |
|||
| cf71825c27 |
fix(mera): отмена по бюджету оставляла осиротевший поток в чужой Session
Ревью 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 |
|||
| a780e3e66e |
fix(estimator): ключевать сделки Росреестра area-бакетом, а не комнатностью клиента
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m10s
`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 |
|||
| 9f696299de |
fix(mera): sync-БД источников эстиматора — с event loop в поток и не через фетч
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-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 Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
Замер на проде 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 |
|||
|
|
de5f4a32cd |
fix(msk): сухой прогон пред-геокода падал на отсутствующей кэш-таблице
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Failing after 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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) Failing after 58s
Кэш `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 |
||
|
|
c5186883a9 |
feat(msk-collector): Яндекс как третья площадка сбора по Москве
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 |
||
|
|
996b814919 |
feat(msk): пред-геокод московского Авито — город из слага, регион из КЛАДР
У карточек Авито нет координат ни у одной из 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 |
||
|
|
491f7d43ac |
feat(msk): полосы цен по округам Москвы и СберИндекс по региону запроса
ПОЛОСЫ. 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 |
||
|
|
50c1df5e0e |
feat(msk): проба покрытия отвечает по Москве — сетка центроидов и радиус на точку
Резолвер города в пробе покрытия знал только 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 |
||
|
|
a82382f47a |
feat(mera-b2c): микроразметка schema.org и картинки превью для публичного сайта
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
Сайт открыт для индексации 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
|
||
|
|
b1727ca39c |
feat(msk): импорт сырья по Москве в listings и region-aware геокодирование
Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали 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 |
||
|
|
2220df8741 |
fix(mera): версия согласия ПДн отстала от новой редакции политики + смоук ждал не тот код
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 5m3s
ДВА ПОСЛЕДСТВИЯ #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 |
||
|
|
671fef758e |
feat(mera): Метрика и GA4 на публичном контуре + открытие сайта для индексации
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI / openapi-codegen-check (pull_request) Successful in 2m6s
CI / backend-tests (pull_request) Successful in 17m40s
ЗАЧЕМ. Статьи МЕРЫ публикуются с 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 |
||
|
|
8fcef103c2 |
feat(msk-collector): Циан как вторая площадка сбора по Москве и МО
Сборщик получает ключ --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
|
||
|
|
9898b6bc02 |
feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у admin geocode-missing (#3051)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
Геокодер был жёстко привязан к Свердловской области: 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 неактивных чужих городов, которые
и раньше геокодились впустую).
|
||
| 12476df7a2 |
Merge pull request 'feat(tradein/estimator): deal_city_price_bands по ключу (region_code, city) — миграция 298, refresh per-region (#3051 sub-PR B)' (#3431) from feat/3051-bands-region-key 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 4m14s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m54s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
|
|||
|
|
1d9adb4a24 |
feat(tradein/estimator): deal_city_price_bands по ключу (region_code, city) — миграция 298, refresh per-region, потребители (#3051 sub-PR B)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / openapi-codegen-check (pull_request) Has been skipped
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 Trade-In / backend-tests (pull_request) Successful in 5m10s
После импорта 212 937 московских ДКП (region_code=77) region_stats refresh'а (p1 для tier region_fallback) считался бы по пулу 66+77 и поднял бы floor ~250 малым городам области. Ключ bands становится (region_code, city): миграция 298 (колонка DEFAULT 66, смена PK через DO-guard, re-seed per-region, ЕКБ-исключение только для 66, doc_type=ДКП), тот же SQL в refresh-джобе, estimator/backtest читают bands по паре. Для 66 набор строк байт-идентичен прежнему (ревью двумя линзами). |
||
|
|
beb487c12e |
test(#3051): тесты трека «Москва» — импорт ДКП по 77, per-source чекпоинт, wildcard-хендлер, регион-скоуп ДКП-коридора, миграция 289, валидация deploy-скрипта
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m2s
Тесты пишутся ПОСЛЕ живой проверки функционала (правило от 08.09): импорт по 77 — run 6460 (212 937 строк), регион-фильтр коридора — smoke до/после импорта. 14 тестов: SQL-текст через inspect (regex \s+ для многострочных клозов), чистые функции, мок db по образцу 2846, subprocess bash с урезанным PATH (падение от валидации, не от отсутствия docker). |
||
| e0bef636e6 |
feat(tradein): bulk-дампы открытых данных ФНС по юрлицам + lookup по ИНН (#3429)
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 4m17s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 7m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
|
|||
| 50f0674977 |
feat(tradein): слой ДТП из dtp-stat.ru в PostGIS + радиусные агрегаты (#3428)
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 4m9s
Deploy Trade-In / build-backend (push) Successful in 1m54s
Deploy Trade-In / deploy (push) Successful in 1m51s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
|
|||
|
|
1f3566c466 |
merge main в feat/mera-cbr-macro — оба handler'а (frt_mkd_load + cbr_macro_pull) в реестре
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
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 5m12s
|
||
|
|
2abab8218e |
feat(mera): макро-ряды ЦБ РФ — ипотека по субъектам и ключевая ставка
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 5m16s
Три XLSX ЦБ (выдачи, ставка, задолженность) в разрезе субъектов, помесячно с 01.2019, + ключевая ставка через SOAP DailyInfo.asmx (метод KeyRate; GET на нём не работает, только POST). Всё анонимно, без ключа. Таблицы: cbr_mortgage_series (region, period_month, series) и cbr_key_rate. fetched_at НЕ обновляется в DO UPDATE — по уроку #2846 колонка значит «когда мы ВПЕРВЫЕ увидели период» и по ней меряется такт публикации источника. Раскладка листов у ЦБ РАЗНАЯ, и это ловушка: в 02_11/02_13 шапка периодов в строке 3 текстом («Январь 2019»), а в 02_14 (задолженность) — уже в строке 2 и настоящими datetime. С захардкоженным индексом строки серия debt_rub молча давала НОЛЬ строк при зелёных тестах. Теперь строка шапки ищется динамически: берётся строка с наибольшим числом распознанных периодов среди первых шести. Проверено на живых файлах: каждая из трёх серий даёт 8 736 точек (96 территорий × 91 месяц, 01.2019–07.2026); по Свердловской области 91 месяц, последняя ставка 11.49. estimator.py не тронут: как ипотечная ставка войдёт в оценку — отдельное продуктовое решение, этот PR только про данные. Миграции 292 (таблицы) и 293 (сид расписания, enabled=false, interval_days 7). Новая зависимость: openpyxl. |
||
|
|
8aca820a46 |
feat(mera): загрузчик реестра МКД АИС ФРТ → обогащение houses
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m14s
Открытые данные АИС ППК «ФРТ» (бывш. Реформа ЖКХ), node 110 = реестр МКД региона 66: 41 790 строк, houseguid (ФИАС GUID) заполнен на 100% → join к houses.gar_house_guid без канонизации адреса. Анонимный GET, без ЕСИА. Заполняет ТОЛЬКО NULL-поля houses: year_built, material_walls, material_floors, total_floors, entrances, is_emergency, flat_count, heat_supply_type, gas_supply_type, hot_water + новые area_land, foundation_type, elevators_total. Замер по ЕКБ: wall_material 92.5% (было 75% от ДОМ.РФ), built_year 92.0% (было 86%), area_land 86.8% и is_emergency — признаков, которых не давал ни один из текущих источников. Осознанно НЕ мапится: - project_type → series_name: свободный текст и фактически дубль материала стен (пусто у 10 635 строк, «кирпичный» 1754, «нет данных» 1496); - energy_efficiency: решение миграции 284 + реальный класс лишь у ~10% домов (у 25 595 из 41 790 значение «Не присвоен»); - elevators_count → passenger_elevators: в источнике это ОБЩЕЕ число лифтов, в houses раздельно пассажирские и грузовые → отдельная колонка; - playground/sportsground: в источнике id справочника (498/499/500), не флаг. Дубликаты houseguid реальны и взаимодополняющи: 912 guid'ов, 968 лишних строк, у одной строки пары заполнен area_total, у парной нет. Строки СЛИВАЮТСЯ по полям (первое непустое побеждает), иначе бэкфилл терял бы данные ~2% домов. estimator.py не тронут: аналоги подбираются FROM listings без JOIN к houses, встраивание признаков в подбор когорты — отдельная задача. Миграции 290 (staging frt_mkd + колонки houses) и 291 (сид расписания, enabled=false, interval_days 30). robots.txt источника требует Crawl-delay 10. |
||
|
|
f820c3eac5 |
fix(msk-collector): ограниченный ретрай goto на таймауте навигации
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
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
Авито изредка держит соединение до упора, goto падает по 90-секундному таймауту и прогон умирает целиком. В наблюдавшемся случае это был не отказ: вкладка показывала нормальную выдачу, маркеров фаервола и PoW не было. Но тихий отказ выглядит так же, поэтому ретрай не слепой: перед каждым повтором читаем то, что есть в документе, и прогоняем через _guard — реальный блок останавливает прогон со своей причиной. Исчерпали 3 попытки — жёсткий стоп с причиной nav_timeout, а не бесконечный повтор. |
||
|
|
3daf09c7e4 |
fix(msk-collector): узкий ретрай page.content() на гонке перенавигации
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Авито дорисовывает выдачу после domcontentloaded, и content() иногда попадает ровно в смену документа — Playwright бросает "page is navigating and changing the content". Это не отказ площадки, но прогон падал целиком: на 1110-й карточке потеряно 100 несброшенных строк. Ретрай узкий — только на этот текст ошибки и только 4 попытки; любая другая ошибка поднимается как есть, гварды блокировки не ослаблены. runs/ (планы и CSV прогонов) в .gitignore. |
||
| e2045582ab |
Merge pull request 'feat(tradein/rosreestr): импорт ДКП по Москве (77) — canonical_city, raw_payload, wildcard-расписание rosreestr_dkp_import_*, per-source чекпоинт (#3051)' (#3422) from feat/3051-rosreestr-import-region-param into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Successful in 39s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 4m22s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m23s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
|
|||
|
|
fcf5887225 |
merge(#3051): main (#3421) в ветку импорта по региону — московская дельта поверх region_code/doc_type
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m12s
#3421 въехал в main параллельно с той же миграцией 288 (deals.doc_type, параметры region_code/doc_types). Разрешение: 288 — целиком версия main; наша дельта (FDW-колонки okato/quarter_cad_number/district, выключенный seed rosreestr_dkp_import_77) переехала в 289. scheduler.py — doc_types из main + canonical_city-маппинг/raw_payload/per-source чекпоинт. deploy-скрипт — валидация REGION_CODE и DOC_TYPE (интерполируются в SQL текстом). |
||
|
|
fbe85fcc75 |
feat(tradein/estimator): регион-скоуп ДКП-коридора — фильтр по d.region_code (#3051 PR-A)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m9s
target_city резолвится ТОЛЬКО для городов Свердловской области (_resolve_target_city матчит SVERDLOVSK_OBLAST_CITIES) — для Москвы/любого нового региона city=None, и street ILIKE оставался единственным скоупом сделки: одноимённая улица чужого региона утекала в коридор. Добавлен d.region_code = CAST(:region_code AS int) в оба ДКП-запроса (_fetch_dkp_corridor) + region_code передаётся из обоих вызывающих (POST /estimate через geo.lat/lon, GET-rehydrate через row.lat/lon) с гарантией «не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL-параметре обнулил бы фильтр целиком). Дефолт региона и джойн deal_city_price_bands не трогаются — следующие PR (B, F). Regression: 5622 passed, 37 skipped (полный прогон tests/). |
||
|
|
1f24f12af9 |
chore(#3051): снять новые тесты до живой проверки; валидация REGION_CODE в deploy-скрипте
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
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 5m13s
Тесты трека «Москва» пишутся отдельным заходом после живого прогона импорта по 77 — правило проекта с 2026-09-08. Правки существующих тестов (bind-параметр вместо литерала 66, канонический source rosreestr_dkp_import_77) остаются. REGION_CODE в import-rosreestr.sh подставляется в SQL текстом — допускаем только целое. |
||
| 541c4ff28d |
Merge pull request 'feat(#3051): deals.doc_type + регион и тип документа как параметры импорта Росреестра' (#3421) from feat/3051-deals-doc-type-region-param into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 41s
Deploy Trade-In / build-frontend (push) Successful in 2m22s
Deploy Trade-In / test (push) Successful in 4m24s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 6m35s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
|
|||
|
|
e9370818ea |
fix(#3051): lock_timeout в миграции 288 — гейт CI #2752
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-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 Trade-In / backend-tests (pull_request) Successful in 5m11s
ALTER TABLE deals ADD COLUMN без SET LOCAL lock_timeout встаёт в очередь за чужой сессией и уводит за собой запросы приложения. Лучше упасть по таймауту и повторить деплой. |
||
|
|
84ee8e5990 |
feat(tradein/rosreestr): параметризовать импорт ДКП по региону, deals.doc_type (#3051)
Трек 2 подготовки Mera к Москве. import_rosreestr_dkp принимает region_code из params (default 66 — байт-в-байт прежнее поведение), валидирует его через app.services.regions.REGIONS. Регион с canonical_city (77 — Москва, Росреестр отдаёт округ/поселение вместо города) подставляет city/address через одну SQL-ветку на bind-параметре :canonical_city, а не Python if/else на код региона; city IS NOT NULL не фильтруется для такого региона (иначе теряется ~10% строк), исходные city/okato/quarter_cad_number/district уходят в raw_payload. Чекпоинт курсора (_resume_dkp_cursor) стал per-region: source для поиска предыдущего прогона строится через _dkp_source_for_region (66 сохраняет легаси-имя 'rosreestr_dkp_import', остальные — суффикс кода) — иначе прогон по 77 либо никогда не резюмился бы (source-литерал не матчил), либо, при более наивном фиксе, унёс бы курсор чужого региона. product_handlers регистрирует wildcard rosreestr_dkp_import_* (по образцу deactivate_stale_*/avito_city_sweep_*), deploy/import-rosreestr.sh получил REGION_CODE env (bash-путь не region-generic — city-override только в Python). Migration 288: deals.doc_type + backfill 'ДКП' для source=rosreestr, foreign table gendesign_rosreestr_deals расширена okato/quarter_cad_number/district (проверено live на прод-БД), выключенный seed rosreestr_dkp_import_77. |
||
|
|
bde38cdce7 |
fix(msk-collector): --resume берёт base_url из сохранённого плана
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
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
Коридоры считаются под конкретный URL выдачи. При --resume код брал свежий args.base_url, поэтому запуск без повтора --base-url молча качал другую выдачу под тем же batch_id. Теперь URL из плана, расхождение с аргументом — явная ошибка вместо тихого выбора одного из двух. |
||
|
|
476fb9b77a |
chore(#3051): снять новые тесты — до живой проверки функционала тесты не пишем
Some checks failed
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Failing after 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m7s
В существующем test_rosreestr_dedup_key оставлена только правка, без которой он падает после параметризации (литералы region_code=66 / doc_type='ДКП' ушли из SQL живого импорта). Новый тест-файл и добавленная функция сняты: поведение ещё не проверено на живом импорте, тест зафиксировал бы догадку. |