Commit graph

1178 commits

Author SHA1 Message Date
99f112db0b Merge pull request 'fix(tradein/auth): единый источник ролей — БД first, YAML только legacy-fallback (закрывает эскалацию до admin и 403 своим)' (#3331) from fix/3316-role-single-source into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 2m8s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
2026-09-05 17:37:38 +00:00
126608e1fc Merge pull request 'fix(tradein/domclick): обрыв «пул пуст» и save-False больше не теряют попытку — attempted == сумма исходов' (#3335) from fix/3332-domclick-counter-identity into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-05 17:36:54 +00:00
64ec4a1aad Merge pull request 'fix(scraper-kit): чекпоинт avito_city_sweep доживает до финализатора — 0/67 прогонов писали done_buckets' (#3330) from fix/3319-citysweep-checkpoint into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-05 17:36:50 +00:00
bot-backend
76b24e9f4d fix(mera-public): подписи метрик снижения цены и бенчмарк отчёта не называют площадку
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 / 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 1m12s
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
Замечание юриста (Сайт_МЕРА_v2, п.3): публичный измеримый контент не должен
строиться на названных данных площадок.

- landing_stats.py: note метрик price_cut_share_pct / price_cut_median_pct_per_month
  «Только Домклик (…)» → «Только одна из площадок (…)». Текст персистится в
  landing_stats.note и рендерится на лендинге в блоке «цена ошибки» — после
  деплоя нужен пересчёт (refresh_landing_stats), деплой кода note не перепишет.
- HeroSummary.tsx / IMVBenchmark.tsx: убраны видимые «IMV» и title
  «Information Market Value» / «Индекс Market Value» — бренд-термин оценочной
  модели площадки; подпись — «оценочная модель» + sourcePublicLabel.
- public-copy-no-platform-names.test.ts: «IMV», «Information Market Value»
  добавлены в BANNED (гейт 34/34).
2026-09-04 11:27:32 +03:00
bot-backend
f2f9ac941b fix(tradein/pdf): клиентский PDF не называет площадки-источники — «Источник N», как в веб-отчёте (#3341)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 5m7s
Веб-отчёт и лендинг с #3342 не показывают названия площадок (канон publicLabel в
frontend/src/lib/source-registry.ts), а клиентский PDF по тому же /estimate/{id}
печатал Avito / Циан / «Домклик · Сбер» / Я.Недвижимость / Этажи в пилюлях
источников, «подтверждают Росреестр, ДомКлик…» в советах и «на Циан, Авито,
Я.Недвижимости» в тарифах. Один клиент — два документа с разной нормой, и юр-риск,
ради которого всё делалось, в PDF оставался открытым.

- `_SOURCE_DISPLAY_NAMES` → публичные лейблы 1:1 с реестром фронта: avito → «Источник 1»,
  cian → 2, yandex → 3, domklik → 4, etazhi → 5, rosreestr → «Росреестр»; fallback для
  незнакомого id — «Другой источник», а не `source.title()` (сырой id — та же утечка).
- Алиасы (avito_imv, cian_valuation, yandex_valuation, domclick, etagi) канонизируются
  ДО выбора цвета и лейбла (`_canonical_source`), а списки источников на страницах
  объявлений и сделок дедуплицируются до среза [:5] (`_public_sources`): иначе
  `sources_used` = listing ∪ valuation давал «Источник 1, Источник 1, Источник 2,
  Источник 2, Источник 4» с серой точкой у алиасов и вытеснял yandex.
- Цвета пилюль не тронуты: цвет — опознаватель источника, как на вебе.
- Тексты: «Росреестр, сделки площадок и продажи агентств», «на основных площадках
  объявлений» (двойник offer-rates.ts).
- Гейт `tests/test_pdf_public_source_labels.py`: видимый текст страниц (без тегов и
  атрибутов, href на домены площадок законны) не содержит названий площадок и сырых
  id, по одному кейсу на имя; дедуп алиасов; ветка совета с процентом.

Не тронуто: ссылки на объявления (avito.ru/domclick.ru) — отдельное решение;
`_QUALITY_SOURCE_SLOTS` — только счётчик, имена не рендерит.
2026-09-03 11:58:45 +03:00
bot-backend
5541aa9d37 fix(mera-public): публичная копия — сервис не называет себя оценщиком, площадки-источники не названы
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 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 5m2s
Три юр-правки по документу владельца продукта «Сайт_МЕРА_v2» (31.08.2026), сделаны
локально 31.08, но не были закоммичены — на meraocenka.ru всё оставалось по-старому.

1. «Оценщик» про собственный алгоритм. Витрина сделок (`landing_showcase_deals.py`,
   REJECTION_RULE/NOTE) и сноска статьи «Как оценить квартиру» теперь говорят
   «расчёт МЕРЫ»: самоназвание обесценивало дисклеймер «не официальный отчёт оценщика».
   Комментарии и докстринги не тронуты — посетитель их не видит.
2. «Путь 2»: «стоимость услуг фиксированная и известна заранее» — читалось как
   фиксированная цена квартиры.
3. Названия площадок убраны из видимой копии лендинга и веб-отчёта. Канон —
   `publicLabel` + `sourcePublicLabel()` в `source-registry.ts`: одна площадка = один
   номер «Источник N» и один цвет точки (цвет остаётся опознавателем между блоками),
   Росреестр под своим именем, неизвестный id → «Другой источник» (раньше fallback
   отдавал сырой id). Три параллельные реализации `sourceLabel` сведены к одной;
   `sourceLabel()` с реальными именами живёт для админки. Backend: якорь
   confidence_explanation «по оценке Avito IMV» → «по оценочной модели площадки».
   Подсказки геокодера «Yandex / Nominatim» и «по Яндексу» сняты из клиентских форм.

Гейт `public-copy-no-platform-names.test.ts` сканирует mera-public/** и
components/trade-in/** без комментариев, по Unicode-границе слова; список запретов
регистрозависимый намеренно (строчные id `"avito"` законны), поэтому капс-варианты
и словоформы перечислены явно — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» в статье именно так
проходил первую версию гейта.

Не закрыто здесь: клиентский PDF (#3341) и ссылки на объявления на доменах площадок.

Текст витрины хранится в БД (`landing_showcase_runs.rejection_rule`,
`landing_showcase_deals.note`), планировщика у пересчёта нет — после деплоя нужен
ручной пересчёт или UPDATE трёх подстрок на проде.
2026-09-03 09:50:41 +03:00
e2e7e17f95 fix(domclick): каждая попытка добора получает исход (#3332)
All checks were successful
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 / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода,
поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1
(прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не
ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев
(#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed.

Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана,
но строки уже нет — попытка была, исхода не было.

Closes #3332
2026-09-02 17:35:48 +05:00
18e0f1d999 test(tradein): изолировать test_rbac.py от состояния реестра (#3316)
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 5m6s
CI-красное на голове ветки: 4 теста в tests/test_rbac.py ждали YAML-роль
(kopylov=pilot, user1=pilot), а в CI-базе реестр засеян миграцией 193
(kopylov=manager, user*=employee) — DB-first резолвер честно отдавал роль из БД.
Локально те же тесты были зелёными ровно потому, что БД нет и работал
YAML-fallback: результат файла зависел от ОКРУЖЕНИЯ, а такой тест не проверяет
ничего.

Чинится не подгонкой чисел в ассертах, а изоляцией: файл проверяет ИМЕННО
legacy-путь roles.yaml (разбор файла, globs, guard и /me на trusted-header), и
теперь заявляет это явно — autouse-фикстура `_legacy_yaml_only` глушит реестр
(`_registry_role` → None). Ассерты на YAML-роли после этого законны в любом
окружении. Приоритет реестра, эквивалентность scope employee↔pilot и
конфигурация kopylov (DB manager + YAML pilot) покрыты отдельно —
tests/test_role_single_source.py.

Проверено обоими способами: полный `pytest tests` без сида и он же с
плагином-имитацией засеянного реестра (подменяется тот же шов, что и в проде,
`identity_store.identity_session`) — 5290 passed, 35 skipped в обоих.
2026-09-02 16:46:44 +05:00
cde95aa9f6 test(tradein): прод-конфигурация ролей в приёмке #3316 + компромисс фолбэка
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
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) Failing after 5m8s
Review PR #3331: приёмка «роли не изменились» гонялась с ПУСТЫМ реестром, а в
проде строка в БД есть у 12 из 13 юзеров и DB-роль ИНАЯ (kopylov: manager при
YAML pilot, user1: employee при YAML pilot). Добавлены два кейса именно этой
конфигурации:

  * YAML pilot + реестр employee → employee, и scope не поехал: allow/deny
    DB_ROLE_PATHS['employee'] сверяются со списками роли pilot из roles.yaml
    целиком — дрейф ЛЮБОГО из двух списков теперь красный тест, а не тихо
    потерянный/выданный раздел в проде;
  * YAML pilot + реестр manager → manager, и лишних путей на tradein-периметре
    нет: manager отличается от employee ровно префиксом /api/v1/team/** (вне
    /trade-in/**), deny-списки совпадают.

Докстринг `_registry_role`: зафиксирован компромисс — при недоступном реестре
фолбэк временно возвращает авторитетность roles.yaml, то есть состояние, которое
фикс и лечит. Сегодня безопасно (прод-коллизий имён нет, новые закрыты
409-гвардом create_employee); появится коллизия — ветку менять на fail-closed.
2026-09-02 14:59:14 +05:00
41f21c4969 docs(scraper-kit): обоснование #3319 — что именно теряло чекпоинт
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 13s
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 5m13s
Ревью опровергло формулировку «точку терял каждый финализатор»: все четыре
писателя в runs.py мержат jsonb (`counters || :counters`), записанный ключ
переживал mark_done/mark_banned/mark_failed. Правка закрывает выходы РАНЬШЕ
первого end-of-anchor heartbeat (cancel/дрейн на первом якоре, ранний done
#1950 на якоре №1) — комментарий и докстринг переписаны на это.

Замер «0 из 67 за 60 дней» назван тем, чем он является: запись появилась
26.08.2026 (#3074) при такте avito 7 суток, выборка почти вся из эры без
механизма. Плюс тест на прогон без ключей (эра до #3074) — метка дрейна не
меняет вердикт «нечего подхватывать».
2026-09-02 14:53:02 +05:00
4feb61c006 fix(tradein): резолвить роль из реестра, roles.yaml — только fallback (#3316)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 15s
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 5m27s
Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`),
а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не
сверял. Дефект двусторонний:

  * вверх: менеджер заводил сотрудника с именем, которое уже числится в
    roles.yaml админом (проверялись лишь regex и уникальность в БД) — на
    входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки
    (admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту;
  * вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на
    СОБСТВЕННУЮ оценку.

Источник теперь один и лечится один раз — в `app.core.auth.get_role`:
реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым,
roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре
нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход.
Вызывающие (rbac, trade_in, team, account_quota) не менялись.

Сопутствующее, чтобы поведение существующих аккаунтов не поехало:
  * rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS),
    иначе employee/manager на legacy-пути получил бы 403 на всё;
  * get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS;
  * право на персональный `unlimited` осталось за roles.yaml (account_quota +
    _batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую;
  * `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список
    «Команды» снова стал бы N+1.

Defense-in-depth: create_employee отдаёт 409 на username, за которым в
roles.yaml числится не-employee роль.
2026-09-02 14:49:27 +05:00
0ed934ea09 fix(scraper-kit): чекпоинт avito_city_sweep доживает до финализатора (#3319)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
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 5m30s
0 из 67 прогонов за 60 дней имели done_buckets в counters: точку писала одна
строка внутри цикла якорей, а каждый выход (mark_done — включая ранний #1950
«SERP собран, detail заблокирован», — mark_banned, mark_failed) отдавал голый
counters.to_dict(). Точка держалась только на jsonb-мерже в runs.py, то есть на
свойстве чужого модуля, которого этот файл не проверяет.

- payload любого выхода собирается одной функцией _ckpt() — done_buckets несут
  все 14 записей, а не одна;
- якорь, умерший по таймауту, больше не считается пройденным (тот же инвариант,
  что у generic-except): SERP мог успеть, detail нет, и резюм пропускал такой
  якорь навсегда при штатно завершившемся прогоне;
- SIGTERM-дрейн помечается counters.interrupted=1 и участвует в резюме. Статус
  остаётся 'done' — ни один читатель статуса не меняется; метка та же, что у
  rosreestr_dkp-дрейна. 'done' в _RESUME_STATUSES НЕ добавлен: чистый полный
  обход резюмить нечего. Дрейн перед IMV-фазой помечен отдельно
  (imv_phase_drained) — якоря там пройдены все, подхват собрал бы ноль.
2026-09-02 14:44:38 +05:00
dab2b43efc imv: HTTP 429 больше не приговаривает дом навечно
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 4m50s
Алерты 01.09 (house_imv: RuntimeError('HTTP 429') / ('HTTP 400')) вскрыли
дыру в классификаторе: _raise_for_status_categorized разбирает 401/403 и 5xx,
а ВЕСЬ остальной 4xx проваливается в resp.raise_for_status() и приезжает в
house_imv_backfill голым RuntimeError. Бэкфилл типизирует только
IMV*-исключения — дом получает ТЕРМИНАЛЬНЫЙ imv_status='error' и выпадает из
повторных пакетов навсегда.

429 — канонический ВРЕМЕННЫЙ отказ (rate limit), и механика повтора для таких
существует (transient_error + retry-lane + лимит попыток #2674). Замер на
проде: 19 домов заперты в error с причиной «HTTP 429» — ретраебельный отказ
стал вечным приговором.

429 и 408 теперь IMVTransientError. 400 НАМЕРЕННО оставлен терминальным: тело
безликое {"code":400,"message":"Bad Request"}, оснований считать его
временным нет, а ретраебельный 400 значил бы вечно долбить дома с реально
кривыми параметрами. Тест держит границу С ОБЕИХ СТОРОН — и «429 transient»,
и «400 НЕ transient».

Фальсификация: снятие ветки 408/429 даёт 2 failed по значению
(IMVTransientError не поднят), не ImportError. 9 passed, ruff чисто.

Ремонт уже запертых строк — отдельным шагом после мержа: UPDATE 19 домов
error→transient_error (починка разбора не чинит строки сама).
2026-09-01 21:21:53 +05:00
add974db65 feat(tradein/avito): бан площадки не менял IP — прогон добивался в тот же забаненный адрес (#3283) (#3313)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m10s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 7m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
2026-09-01 08:05:48 +00:00
bot-backend
d708f15019 fix(tradein/support): один повтор терял каждое одиннадцатое сообщение в поддержку
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 4m50s
Замер прода 01.09.2026 из контейнера бота: канал до api.telegram.org рвётся
всплесками, доля отказов на попытку 15-38% (пять проб: 3/8, 15/40, 5/20, 3/20,
1/25), в логе long-polling'а 353 ConnectTimeout за сутки. Транспорт ни при чём —
httpx и сырой сокет отваливаются одинаково (25% против 35% в чередующемся
замере), и прокси не помогает, а мешает: через SCRAPER_PROXY_URL 0 из 20.

Ручка веб-поддержки ходила с max_retries=1, то есть двумя попытками. При 30%
отказов на попытку до пользователя доходило ~9% отказов — каждое одиннадцатое
сообщение возвращало 502 «сервис недоступен».

Два других числа из того же замера задают конструкцию. Успешный запрос отвечает
за 0.13с (максимум из 25 проб — 0.18с), а неудачный НИКОГДА не отваливается
быстро: все отказы упираются в таймаут целиком (10.02с при timeout=10.0). Значит
десятисекундный таймаут не покупал ничего, кроме цены за неудачу, — снижен до 5с,
это ~28-кратный запас к измеренному максимуму. И экспоненциальная пауза 2→4→8с
здесь бессмысленна: отказ — неустановленное соединение, а не троттлинг, пережидать
нечего; она лишь добавляла 14с к ожиданию.

Правка: бюджет ручки — 3 повтора, таймаут 5с, потолок паузы 1с. Худший случай
4 попытки × 5с + 3 паузы × 1с = 23с и требует четырёх отказов подряд; типичный
случай не меняется (0.13с). Расчётная потеря падает с ~9% до ~0.8%.

В TelegramClient добавлен необязательный max_backoff. Воркерная политика НЕ
меняется: без явного потолка откат прежний экспоненциальный до 30с, а retry_after
из 429 уважается целиком — эту границу держит отдельный тест, потому что первая
версия правки её сломала (капала 60с до 30с и для воркера тоже). Потолок на
retry_after применяется только когда его передали явно: интерактивному пути
нельзя ждать Telegram-овские 30-60с, за ним стоит открытый запрос от браузера.

Тесты: 8 новых (потолок на network/429/5xx, неизменность воркерного пути,
арифметика «max_retries=N → N+1 попыток», границы бюджета ручки).
2026-09-01 09:53:21 +03:00
4c29b59041 fix(tradein/proxy): успешный fetch затирал exit_ip и latency_ms в NULL (#3283) (#3305)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
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) Has been cancelled
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-08-31 14:53:33 +00:00
72cb410b05 fix(tradein/avito): бан-страница площадки уезжала в транспортные сбои и не банила узел (#3283) (#3304)
Some checks are pending
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-08-31 14:53:17 +00:00
9219ab6507 feat(tradein/avito): ротация exit-IP по счётчику попыток в доборе карточек (#3302)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m8s
Deploy Trade-In / build-backend (push) Successful in 1m56s
Deploy Trade-In / deploy (push) Successful in 1m43s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-31 12:09:03 +00:00
b7c1c4d9d4 fix(tradein/proxy_pool): истёкший прокси больше не выдаётся в аренду (#3303)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m14s
Deploy Trade-In / build-backend (push) Successful in 1m16s
Deploy Trade-In / deploy (push) Successful in 7m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-08-31 11:54:54 +00:00
ea8943e00e Merge pull request 'chore(tradein/scheduler): добор карточек Яндекса и Циана шёл раз в сутки и простаивал' (#3301) from chore/cadence-yandex-cian-detail into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m17s
Deploy Trade-In / build-backend (push) Successful in 1m48s
Deploy Trade-In / deploy (push) Successful in 1m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-08-31 11:36:24 +00:00
dbb8ca4ec1 Merge pull request 'fix(tradein/avito): browser-путь добора не смотрел на HTTP-статус — отказ площадки уходил в ошибку парсинга (#3297)' (#3300) from fix/3297-avito-browser-status-check into main
Some checks failed
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
2026-08-31 11:36:19 +00:00
bot-backend
4ddb3c7196 chore(tradein/scheduler): добор карточек Яндекса и Циана шёл раз в сутки и простаивал
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 Trade-In / browser-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 4m58s
compute_next_run_at держит суточную гранулярность (interval_days, минимум 1),
подчасовой такт делается хуком reschedule_after_minutes как post_claim. У
avito_detail_backfill он есть (180 мин), у yandex_detail_backfill и
cian_detail_backfill не было — отсюда один прогон в сутки.

Цена простоя по замеру прода 31.08:

  Яндекс: 375-450 карточек за прогон, блоков НОЛЬ за неделю, очередь 11110
          → 25 суток при нынешнем такте
  Циан:   блоков ноль, очередь 20501

Такты разные, и это не произвол:

  yandex — 180 мин (8 прогонов/сутки). Ходит через resolve_proxy_url: берёт
           URL узла, но НЕ лизует его, поэтому чужие прогоны не блокирует.
  cian   — 360 мин (4 прогона/сутки). Ходит через BrowserFetcher и ДЕРЖИТ
           lease весь прогон, то есть отнимает узел у Авито и Домклика. Пул
           дефицитен (#2638), поэтому осторожнее.

Асимметрия зафиксирована комментарием у обоих хендлеров и в докстринге
миграции — иначе следующий читатель выровняет интервалы и сожжёт пул. Тест
test_cian_default_interval_is_360_minutes_not_180 ассертит именно неравенство,
чтобы выравнивание без замера покраснело.

283_scrape_schedules_cadence_yandex_cian_detail.sql — идемпотентный
UPDATE ... SET default_params = default_params || jsonb, остальные ключи
параметров не трогает.

Значения 180/360 — консервативная отправная точка по аналогии с Авито, а не
найденный оптимум: двигать вниз только по замеру нескольких суток, глядя и на
свипы тоже (тот же довод, что в комментарии у avito_detail_backfill).

Тесты (5): наличие post_claim у обоих, дефолтные интервалы, переопределение
через params. Прогон: 397 passed, 1 skipped, ruff чист.
2026-08-31 14:17:12 +03:00
bot-backend
5c10aee4c2 fix(tradein/avito): browser-путь добора не смотрел на HTTP-статус — отказ площадки уходил в ошибку парсинга (#3297)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 5m4s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Замер прода 31.08.2026 (24 попытки через 3 узла) поймал три отказа, которые
доезжали до parse_detail_html и падали ValueError("Cannot extract item_id"):
страница 8172 байта, статус 439, title «Доска объявлений от частных лиц и
компаний на Авито». Отказ ПЛОЩАДКИ записывался generic-ошибкой разбора, узел
не ротировался и не банился, брейкер по доле его не видел.

Две независимые дыры, обе в browser-ветке fetch_detail:

1. last_response_status не читался вовсе. Curl-ветка того же файла статус
   проверяет (`if sc in (403, 439) or is_firewall`), browser-ветка смотрела
   только на HTML. Прод ходит именно browser-путём.

2. Маркер витрины-заглушки протух: искали «объявления на сайте авито», а
   фактический title — «доска объявлений от частных лиц и компаний на авито»,
   подстрока в нём не встречается.

Правка:

- новые константы _AVITO_DETAIL_BROWSER_BLOCK_STATUSES = {403, 439} и
  _AVITO_DETAIL_BROWSER_RATELIMIT_STATUS = 429, источник каждого статуса
  назван комментарием;
- проверка стоит ПОСЛЕ _is_detail_not_found (404 остаётся
  AvitoListingGoneError) и ДО parse_detail_html;
- статус None (сайдкар старой версии, goto без статуса) отказом НЕ считается —
  поведение прежнее, фолбэк на html-эвристики;
- 429 разведён с блокирующими статусами и поднимает AvitoRateLimitedError.
  Разница не косметическая: на AvitoBlockedError оркестратор один раз за прогон
  зовёт request_context_reset (#3251) и выбрасывает пройденный QRATOR-PoW. При
  rate-limit контекст цел, сбрасывать его — значит проходить проверку заново с
  того же IP. Зеркалит curl-ветку, где 429 тоже не блок;
- старый title-маркер не удалён, а дополнен снятым вживую: площадка может
  отдавать обе формы.

Тесты (9): каждый статус по отдельности, None-статус не ломает разбор и не
подавляет html-эвристики, 404 побеждает блокирующий статус (порядок проверок),
оба title-маркера опознаются, 429 не является AvitoBlockedError.

Фальсификация: без правки detail.py 5 из 9 новых тестов падают.
Прогон: 397 passed, 1 skipped (-k "avito or cadence or scheduler"), ruff чист.

Замер намеренно жёстче прода (без прогрева сессии и органического перехода из
выдачи), поэтому доля таких отказов в проде из него НЕ следует — её покажет
счётчик после правки.
2026-08-31 14:16:47 +03:00
a256077211 feat(tradein/proxy_rotation): ротация IP через mobileproxy.space (aproxy.site) (#3298)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m11s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 7m37s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
2026-08-31 11:01:41 +00:00
f7d428c0c5 Merge pull request 'витрина: строка без улицы больше не открывает страницу и игру' (#3291) from fix/mera-showcase-order-street into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-browser (push) Successful in 41s
Deploy Trade-In / build-frontend (push) Successful in 2m41s
Deploy Trade-In / test (push) Successful in 4m22s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-08-31 09:14:48 +00:00
884a6b9cfe бэктест: режим выборки вразброс — ORDER BY id DESC садится на несколько улиц
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 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 5m12s
ORDER BY id DESC берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр грузится
пачками по домам: соседние id это один дом и одна улица. Замер на проде
31.08.2026 по Екатеринбургу, выборка 200:

    последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
    случайные 200       : 129 разных улиц, максимум 6 с одной

На этой выборке стоит витрина лэндинга: 20 строк с ЧЕТЫРЁХ улиц, 16 из них с
двух. Владелец заметил это как «почти все сделки из одного района».

ЧТО ЗАМЕР ПОКАЗАЛ, а что нет. Пять прогонов по 200 сделок:

    recent            MAPE 15.18%  покрытие 88.75%
    scattered mera    MAPE 14.96%  покрытие 83.13%
    scattered alpha   MAPE 15.97%  покрытие 88.34%
    scattered bravo   MAPE 13.69%  покрытие 83.85%
    scattered charlie MAPE 14.36%  покрытие 86.23%

Заголовочная точность УСТОЯЛА — опубликованные 14,5% лежат внутри разброса
представительной выборки. Кластеризация её не раздувала.

А покрытие коридором опубликовано как 88% — это верх диапазона: при пересборке
выборки величина гуляет 83-88 при медиане около 86. Публикуется лучший прогон
из пяти как единственный. Правка самих чисел лэндинга — отдельным заходом.

УМОЛЧАНИЕ НЕ ТРОНУТО. Докстринг _sample_sql обещает дефолтному пути побайтово
тот же SQL ради замороженного регресс-гейта; смена умолчания молча обнулила бы
сравнимость всей истории замеров. Режим выбирается флагом --spread.

Порядок по md5(id||seed), а не random(): нужен ВОСПРОИЗВОДИМЫЙ порядок, иначе
два прогона отличаются и из-за правки, и из-за состава выборки, и разделить
вклады нечем.

Тест держит обе стороны и проверен фальсификацией: сделать вразброс умолчанием
— падает identity-проверка (сравнение текстов её бы пропустило), заменить md5
на random() — падает проверка воспроизводимости.
2026-08-31 11:28:11 +05:00
e45b05845e fix(mera-landing): наличие схемы улицы входит в полноту строки витрины
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Первой строкой витрины и первым раундом игры «Проверьте себя» стояла
единственная из двадцати сделка БЕЗ улицы: у неё вместо схемы улиц
рисовался полигон района. Причина — `completeness()` считала район, этаж
и этажность, но не улицу, хотя именно она решает, будет ли у строки карта.

Схема улицы — такое же ВИДИМОЕ поле, как район: строка, которой нечем
нарисовать карту, полнее строки с картой быть не может. Признак берётся
из уже загружаемого индекса улиц (`StreetIndex.lookup`, поиск в памяти);
индекс поднят выше отбора, дорогие пространственные запросы остались в
`_schemes_for` и по-прежнему считаются только для показанных строк.

Отбор по ВЕЛИЧИНЕ ОШИБКИ не введён и введён быть не может: строка
с отклонением +75,7% остаётся в витрине, просто больше не открывает её.
Прежнее правило «наличие схемы на отбор не влияет» в докстринге
`_schemes_for` заменено с разбором, почему оно давало этот дефект.

Тест двусторонний: при прочих равных строка со схемой выше строки без
неё, а строка без улицы остаётся в витрине. Фальсифицирован — снятие
`row.has_street` из `completeness` даёт красное ПО ЗНАЧЕНИЮ ([9, 8]
вместо [8, 9]), не по исключению.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:22:39 +05:00
bot-backend
1e02badbe9 fix(tradein/domclick): бан сайдкара наследуется от httpx-ошибки и уезжал в транспортные сбои (#3283)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
Правка #3286 разделила отказы площадки и сбои нашей стороны по природе
исключения: httpx-ошибка в цепочке причин = транспорт. Прод показал дыру
на первом же прогоне (5406):

  СБОЙ #1/400 (подряд=1/10, http=401, kind=platform, не блок площадки):
  DomClick detail: sidecar detected platform refusal

Заголовок «не блок площадки» стоит над записью, где kind=platform и код 401,
то есть над самым что ни на есть отказом площадки. Причина в иерархии:
SidecarBanPageError объявлен как подкласс httpx.HTTPStatusError, поэтому
проверка на httpx, стоявшая первой, забирала генуинный бан себе.

Порядок проверок перевёрнут: бан сайдкара отсекается ДО общей httpx-ветки.
Остальное поведение #3286 не тронуто — обычная 500 от сайдкара по-прежнему
транспортный сбой.

Тесты: +4, включая закрепление самой предпосылки (issubclass проверяется
явно, чтобы смена иерархии не отключила проверку молча) и страховку, что
обычная 500 осталась сбоем. Проверено мутацией: снятие порядка роняет тест
и печатает самопротиворечивый лог «10 сбоев подряд без единого отказа
площадки, диагнозы: {'platform': 10}». Набор целиком — 5210 passed.
2026-08-30 17:02:46 +03:00
bot-backend
42913d2843 fix(tradein/domclick): таймаут сайдкара и пустой пул считались отказами площадки (#3283)
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 4m56s
Прогон 5399 умер по правилу «3 блока подряд», не обогатив ни одной карточки
из 400. Отказом Домклика не был ни один из трёх: два — HTTP 500 от сайдкара
(таймаут навигации), третий — пустой пул, при котором запрос к площадке не
отправлялся вовсе. Лог абортa сам это печатал: диагнозы {'unknown': 3}.

Различать по HTTP-статусу нельзя: настоящий QRATOR-челлендж приходит без
статуса и неотличим от таймаута — на этом и стояли прежние тесты. Различима
природа исключения, причём разделение уже проведено в fetch_detail: генуинный
бан приезжает через SidecarBanPageError или маркеры в parse_detail_html, а
транспорт — через  с , то есть с
httpx-ошибкой в цепочке причин.

Три правки:
* пустой пул (NoProxyAvailableError в цепочке) — не блок, а остановка прогона:
  продолжать бессмысленно, каждая следующая карточка упрётся в то же. Прогон
  уходит в failed, а не banned — запись не должна утверждать про площадку то,
  чего не было;
* транспортные сбои идут в failed, туда же, куда давно идёт DomClickParseError
  («schema drift, not a block»), и счётчик блоков не трогают;
* отдельный сторож на молчаливый отказ: max_consecutive_soft_failures=10, иначе
  снятие хайртриггера оставило бы прогон без тормоза вовсе.

Тесты: 10 проверок, включая дословный сценарий 5399 и страховку «генуинные
блоки по-прежнему рвут прогон после трёх». Проверено мутациями: возврат пустого
пула в блоки роняет 2, возврат транспорта — 6, снятие сторожа — 2. Четыре
существовавших теста на генуинный блок продолжают проходить без правки — это и
показывает, что разделение проведено по верному признаку. Набор целиком —
5206 passed, 37 skipped.
2026-08-30 16:33:56 +03:00
bot-backend
5f9dc5d512 feat(tradein/cian): у Циана не было добора карточек — только побочный эффект задачи про историю (#3284)
All checks were successful
CI Trade-In / 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 / 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 Trade-In / backend-tests (pull_request) Successful in 5m2s
Карточки Циана доставались побочным эффектом cian_history_backfill, а её
выборка ключуется по offer_price_history. Следствия на 30.08: карточка есть
у 4494 из 25222 объявлений (17.8% — последнее место при втором месте по
объёму), 19046 без истории при квоте 100/сутки (190 дней на остаток, тогда
как очередь растёт вдвадцатеро быстрее), и 1697 объявлений с историей и без
карточки, которые исторической выборке недостижимы в принципе.

Фетчер при этом исправен: прогоны 5154/5240/5328 дали 100/100, 99/100,
100/100. Чинить нечего — не выдана мощность.

Добавлен второй режим выборки (listings_pending="detail", по
detail_enriched_at, свежие первыми) и второе расписание поверх ТОГО ЖЕ тела:
машинерия работает, дублировать её новым модулем незачем. Историческая
выборка оставлена побайтово — по ней живёт суточный прогон.

batch_size=400 не на глаз: замеренный темп ~28с на объявление, порог
reap_zombies 6ч по heartbeat, бюджетного сторожа у задачи нет — 400×28с≈3.1ч
проходит, 800 как у Яндекса (≈6.2ч) убивало бы жнецом.

Расписание засеяно enabled=false, как domclick_detail_backfill в миграции
175: это третий круглосуточный добор на общий пул из четырёх узлов, влияние
на соседей надо посмотреть, а не предположить.

Тесты: 13 проверок, ключ выборки / порядок / неизменность прежнего режима /
проводка параметров через посредника / регистрация обоих source. Проверено
мутациями: снятие ORDER BY, молчаливый дефолт вместо ValueError и потеря
listings_pending в посреднике роняют по 2-3 теста каждая. Набор целиком —
5196 passed, 37 skipped.
2026-08-30 15:34:36 +03:00
8fb5587888 Merge branch 'main' into fix/3194-hide-sql-parameters
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 19s
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 / openapi-codegen-check (pull_request) Successful in 3m10s
CI Trade-In / backend-tests (pull_request) Successful in 5m56s
CI / backend-tests (pull_request) Successful in 18m10s
2026-08-30 10:23:05 +00:00
663d63aea4 fix(tradein): подсказки адреса — пустые части адреса выпадают, а не печатаются
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 16s
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 5m50s
Прод отдавал «г Екатеринбург, ул Фролова , д. 29, корп.»: реестровый
readable_address (ЕГРН) приходит УЖЕ склеенным вместе с пустыми частями —
маркер без значения печатается, лишний пробел перед запятой остаётся, и все
тиры /suggest (cadastral / geoportal / houses / DaData / Nominatim) пропускали
строку насквозь.

Чиним склейку, а не конкретный случай: `tidy_address()` — один проход по
частям (схлопнуть пробелы, выбросить пустые и маркер-без-номера:
корп./стр./лит./кв./оф./пом. и пр.), склейка обратно. Применяется в
`GeocodeSuggestion.__post_init__` — единственной точке, через которую проходят
все тиры, включая локальные, которые питают и `geocode()`.

Тест на функцию склейки, фальсифицирован снятием фильтра пустых: краснеет по
значению строки (10 из 14), а не исключением.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:58:03 +05:00
7e59c1e5b0 fix(#3194): hide_parameters=True на всех движках, include_local_variables=False у scheduler
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 / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Successful in 3m10s
CI Trade-In / backend-tests (pull_request) Successful in 5m47s
CI / backend-tests (pull_request) Successful in 18m3s
Ключ шифрования кук и сами куки уезжали в GlitchTip: сервисы сессий передают
их bind-параметрами в pgp_sym_encrypt(:cookies_json, :key), а SQLAlchemy при
ошибке печатает ВСЕ параметры в тексте StatementError.

Правка на уровне движка (backend + tradein-mvp: db.py, auth_db.py,
alembic/env.py) кроет все сайты вызова разом, включая четвёртую копию в
scraper-kit и всё будущее.

scheduler_main.py был единственным из трёх sentry_sdk.init без
include_local_variables=False — процесс скрейпера, в кадрах лежат прокси-креды.

НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением) и
сырые psycopg-подключения мимо движков — отдельный класс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:53:41 +05:00
bot-backend
657fdbd1ba Merge remote-tracking branch 'forgejo/main' into fix/3267-block-marker-in-listing-text
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 4m51s
2026-08-30 12:08:46 +03:00
bot-backend
1bf9c4b632 test(tradein): гонка на sleep(0.05) вешала тест потолка оценок на 120с (#3270)
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 11s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
Тест занимал все слоты семафора четырьмя висящими запросами и ждал, что
они дошли до acquire, обычным сном на 50 мс. На нагруженном раннере этого
не хватало: пятый запрос заставал свободный слот, входил в подменённую
оценку и вставал на gate.wait() — а gate.set() стоит ниже по тому же
корутину. Дедлок до pytest-timeout, две минуты простоя job'а и красный CI
на постороннем PR (#3268).

Сон заменён счётным барьером: подменённая оценка отпускает семафор при
входе, тест дожидается ровно _CONCURRENCY входов. Вход означает, что слот
уже захвачен, — это то самое условие, которое сон угадывал по времени.

Проверено пробой с намеренно свободным слотом (держателей на одного
меньше): старая структура висит до таймаута, новая падает за 12с с
понятным сообщением.
2026-08-30 12:02:45 +03:00
169121cea8 Merge pull request 'fix(tradein/avito): браузерный путь ходил на каждую карточку холодным (#3251)' (#3267) from fix/3180-avito-warm-context into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m19s
Deploy Trade-In / test (push) Successful in 4m11s
Deploy Trade-In / build-backend (push) Successful in 2m9s
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
2026-08-30 08:55:33 +00:00
bot-backend
aec05280fa fix(tradein/domclick): «система защиты от протечек» в объявлении читалась как блок QRATOR
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI / 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 / 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 7m5s
_extract_json искал маркеры блока подстрокой по ВСЕМУ телу ответа — до
разбора JSON, то есть и по пользовательским описаниям объявлений.

Прод 30.08, прогон 5363: продавец написал в описании квартиры «🔹 Система
защиты от протечек». Подстрока совпала с маркером «система защиты», ответ
на 107 183 байта с двадцатью валидными офферами был объявлен блок-
страницей, свип оборвал все комнатные корзины и забанил живой узел пула
на 6 часов. Совпасть так же могут «captcha» и «qrator» — в тексте
объявления, в имени агентства, в ссылке.

Порядок перевёрнут: сначала разбор JSON, маркеры — только если разбор не
удался. Разобранный JSON нужной формы блок-страницей быть не может,
QRATOR отдаёт HTML, так что валидный разбор сам по себе доказывает
отсутствие блока. Тот же порядок давно применён в detail.py — там
маркеры смотрят только когда __SSR_STATE__ не найден; свип был
единственным местом с обратной логикой.

Мусор без маркеров теперь ValueError, а не блок: за неразобранный ответ
неизвестной природы узел банить нельзя.

Тест проверен мутацией: со старым порядком 8 проверок из 11 краснеют,
включая дословный фрагмент описания из прогона 5363.
2026-08-30 11:49:47 +03:00
bot-backend
58e18e6fec fix(tradein/avito): браузерный путь ходил на каждую карточку холодным (#3251)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
Пройденный QRATOR proof-of-work выбрасывался после каждой карточки: `reuse_context`
во всём репозитории передавал ЕДИНСТВЕННЫЙ вызов — domclick_detail_backfill.py:346.
Значит каждый /fetch Авито шёл через sidecar'овский browser.new_page(), то есть
новый изолированный context с пустой банкой кук. В логе прод-сайдкара это видно
прямо: «PoW-челлендж снят за ~1000мс» печатается на КАЖДОЙ успешной карточке —
челлендж решается заново каждый раз, а не один раз на прогон.

Эффект той же правки у Домклика измерен и записан в domclick_detail_backfill.py:331
— 26 последовательных фетчей сайдкара дали 100% блоков, те же карточки в тёплом
контексте 5/5 за ~2с.

Второй холод — сам заход: providers/avito/detail.py звал fetch(full_url) голым,
без origin и без Referer, тогда как Домклик (#3247) идёт fetch(card_url,
origin=SERP, referer=origin). Существующий для Авито прогрев (warm_up_session)
живёт только в curl-пути и с 22.08 в проде мёртв — AVITO_DETAIL_BACKFILL_USE_CURL
выставлен в false.

Что сделано:
- avito_detail_backfill: reuse_context=True + request_context_reset() РОВНО один
  раз за прогон и только на AvitoBlockedError. Не на AvitoSidecarUnavailableError
  (подтип AvitoRateLimitedError — отказ нашего тракта, не бан площадки) и не на
  AvitoListingGoneError. Зеркалит #3212: сброс на каждый блок сам себя
  поддерживает — пропуск живёт в context'е, сброс его выбрасывает, повторная
  проверка с того же IP снова блокируется, одна осечка даёт каскад.
- fetch_detail: необязательные origin/browser_referer (имя referer уже занято под
  Referer curl-пути, это разные фетчеры и разные поля). Дефолт None → payload и
  поведение city_sweep/pipeline/admin не меняются.
- _serp_origin_for: городская SERP из URL карточки, хост берётся из самого url.

Попутно — дефект якорной вкладки сайдкара, найденный при переносе. _ensure_anchor_page
отдавала True на ЛЮБУЮ живую вкладку, не сверяя её с запрошенным origin. А origin у
обоих caller'ов выводится ИЗ URL карточки и меняется вместе с городом (Авито —
сегмент пути, Домклик — поддомен). После первой же карточки другого города Referer
называл выдачу, которую этот контекст никогда не открывал: ни куки её, ни тайминга,
площадка видит заявленный переход без единого следа. Ровно то, что #3258 запретил
делать фолбэкам якорного поиска. Добавлен _anchor_origins: origin сменился — вкладка
переоткрывается. Чинит и Домклик тоже.

Заход через поиск Яндекса (BROWSER_ANCHOR_VIA_SEARCH) для Авито НЕ включается —
расширять этот список без отдельного замера запрещает комментарий у самой константы.
Хранилище авторизованных сессий Авито (#3179/#3180) этой правкой не заменяется.

База для сравнения снята ДО выката и записана в #3251: доля блоков от попыток
46.9% / 74.2% / 96.4% / 69.8% / 52.9% / 62.3% по суткам 25-30.08. Сравнивать после
деплоя по доле блоков и обогащению за сутки, а НЕ по статусу прогона: ratio-критерий
обрывает КАЖДЫЙ прогон, и статус banned про площадку ничего не говорит.

Тесты: backend 5150 passed / 37 skipped, сайдкар 211 passed (было 206 + 5 новых на
переезд якоря), ruff чист.

Refs #3251, #3180, #3118, #3212, #3247, #3258
2026-08-30 11:47:15 +03:00
bot-backend
5baf07d6e6 fix(tradein/domclick): свип брал BFF навигацией браузера — теперь подзапросом (#3264)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 4m53s
BFF-ручка Домклика не страница, а JSON-эндпоинт SPA. Сайдкар умел ровно
одно — page.goto(url), — и навигацией браузера на API-хост мы делали то,
чего настоящий клиент не делает никогда. Перехват сети на живой выдаче
30.08 это и показал: офферы приезжают в SSR-документе, а к BFF ходят
XHR'ы за гео, районами и метро.

Через мобильные узлы пула такая навигация упиралась в ChallengeTimeout:
прогоны 5330 и 5351 — 9 из 9 и 6 из 6 запросов зависли на челлендже, 0
лотов. Карточки через те же узлы в те же минуты шли.

Замер трёх режимов на одном узле, один и тот же ресурс:

  BFF navigate     ChallengeTimeout, 55с
  BFF subresource  HTTP 200, 52 байта, 6с
  BFF page_fetch   ошибка

Подзапрос проверен вширь: все шесть комнатных корзин HTTP 200, суммарно
6359 офферов против 6367, снятых напрямую с резидентного IP (расхождение
— дрейф фонда за пару часов); список офферов отдаётся на всех четырёх
узлах пула, по 20 штук, 120 КБ.

Сайдкар получил fetch_mode: navigate (дефолт, прежнее поведение),
subresource (context.request.get из прогретого контекста) и page_fetch
(fetch из страницы). Третий оставлен, потому что теоретически он ближе
всего к настоящему XHR, но в замере отказал — выбор сделан измерением, а
не рассуждением. Прогрев origin обязателен: рукопожатие QRATOR попадает
в куки контекста именно при заходе на страницу, поэтому свип теперь
передаёт origin и referer, которых раньше не передавал вовсе.

_decode_body распаковывает gzip по магическим байтам — карта офферов
приезжает Content-Type: application/gzip, и без этого вызывающий получил
бы бинарь в поле "html". Сама карта, к слову, подзапросом всё равно не
берётся (279 байт — загрузчик QRATOR), но BFF полнее: 100% фонда против
62% у карты.

fetch_mode кладётся в payload только когда он не дефолтный — сайдкар
прежней версии не должен получать незнакомый ключ (тот же приём, что с
referer в #3247).

Тесты: сайдкар 212 passed (новый test_server_fetch_mode.py — 6
проверок), backend 316 passed (новый test_3264_sweep_subresource_mode.py
— 5). Три тестовых дублёра _do_fetch/_post_fetch знали старую сигнатуру
— обновлены.
2026-08-30 11:24:28 +03:00
bot-backend
44633b0df4 fix(tradein/domclick): свип ходил на QRATOR без кук и вис на PoW каждым запросом (#3264)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 4m53s
serp.py не передавал куки сессии вообще — слова cookie в файле не было.
Свип приходил к QRATOR с чистым браузером и был обязан решать
proof-of-work с нуля на каждый запрос. Прод, прогон 5330 (30.08
03:49-03:55): 9 запросов к bff-search-web.domclick.ru, все 9 зависли на
челлендже (перезагрузка 1/2, 2/2, отказ), status=failed, 0 лотов. Прокси
при этом ротировался (узел 9 → 10) — узел тут ни при чём.

Добор с тем же сайдкаром и тем же пулом в ту же ночь взял 37 карточек из
37 без единого блока. Разница ровно в куках.

Чинить это стало возможно только сейчас: свип ходит не на
ekaterinburg.domclick.ru, а на отдельный хост bff-search-web.domclick.ru,
и до правки _cookie_domain (PR #3262) куки легли бы на
.bff-search-web.domclick.ru, не совпав с сессией площадки. Теперь оба
хоста схлопываются в общий .domclick.ru.

Снимок приходит параметром снаружи, а не читается внутри kit: kit не
импортирует app.* (strangler-инвариант #2133). Поэтому джоба
domclick_city_sweep переопределена продуктовым Handler'ом — build_registry
это прямо допускает («последнее слово за продуктом»), а БД читает только
app-сторона.

Отсутствие сессии не авария: load_session вернул None → свип идёт как
раньше, без инъекции, факт логируется один раз.

Цена решения — override повторяет вызов kit-джобы целиком и может тихо с
ней разойтись. Добавлен тест, который зовёт оба джоба одинаково и
сравнивает наборы kwargs, допуская расхождение ровно в cookies. Проверен
мутацией: с искусственно добавленным в kit-версию аргументом краснеет,
без него зелёный.

Тесты: 245 passed, 1 skipped (domclick + parity).
2026-08-30 09:40:21 +03:00
1d45ac0747 feat(mera/estimate): ручка фактов дома для предзаполнения формы + гейт «этаж не выше дома» (#3257)
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
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 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Has been cancelled
2026-08-29 20:34:00 +00:00
5da226a271 fix(mera/estimate): перефит хедоники поверх area-бакетного ratio — крупное жильё занижалось на 21% (#3255)
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 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m34s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-08-29 20:19:27 +00:00
e523c8949c fix(mera/backtest): свежая фикстура не реплеилась, а разрез по сегментам врал по построению (#3254)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m58s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-08-29 20:11:11 +00:00
2b62d279fe Merge pull request 'feat(tradein/browser): ходить по Домклику как человек — с Referer и с перезагрузкой зависшего рукопожатия' (#3250) from feat/browser-referer-on-card-nav into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m11s
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m44s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-29 19:59:12 +00:00
0bdc7227a3 docs(mera/estimate): сегментные множители откалиброваны на артефакте измерения — предупреждение в конфиге (#3249)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
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) Has been cancelled
2026-08-29 19:55:41 +00:00
bot-backend
ff0a15d443 feat(tradein/browser): ходить по Домклику как человек — с Referer и с перезагрузкой зависшего рукопожатия
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
Добор карточек Домклика упирался в отказ на 11-й карточке: прогон 5298 дал
attempted=13, enriched=10, blocked=3. Ручные прогоны в живом браузере брали 91 и
40 карточек без единого отказа. Разбор нашёл два отличия, и оба оказались нашими,
а не площадки.

1. Referer не отправлялся НИКОГДА. playwright'овский goto() по умолчанию этот
   заголовок не шлёт, а параметр `referer`, который он принимает, мы не передавали.
   Площадка видела десяток появлений подряд прямо на URL карточки, без источника
   перехода — так не ходит ни один человек. Комментарии в коде при этом уверяли
   про «органическую навигацию с реальным Referer»; они врали, теперь исправлены.
   Сайдкар принимает `referer` в теле /fetch и ставит его ТОЛЬКО на целевую
   навигацию; на origin и якорную вкладку не ставит — туда приходят «сами».
   Добор Домклика передаёт страницу выдачи, чем переход и является по смыслу.

2. Зависшее рукопожатие не перезагружалось. _wait_out_pow_challenge построен на
   допущении «страница перезагрузит себя сама после решения PoW»; ручная сессия
   29.08 через узел 10 это опровергла — выдача осталась на 401, и пропуск
   qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных руками:
   102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, следом кука-пропуск, и
   карточка за 6 секунд. Пока мы только опрашивали content(), такая страница жила
   до таймаута, а бэкфилл засчитывал это в блоки. Теперь после
   BROWSER_CHALLENGE_RELOAD_AFTER_MS (8с) сайдкар перезагружает сам, не больше
   BROWSER_CHALLENGE_MAX_RELOADS (2) раз за фетч.

Оба пути безопасны на откат: без поля `referer` в теле поведение прежнее,
BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает прежний опрос без навигаций,
упавшая перезагрузка не роняет фетч — опрос продолжается в том же бюджете.

Тесты: 191 passed в сайдкаре (было 182) — 4 на Referer, 5 на перезагрузку, в том
числе «страница ожила сама → лишней навигации нет» и «висит вечно → не больше
лимита». Точечные backend-тесты домклика и scraper_kit — 144 passed.
2026-08-29 22:53:11 +03:00
e3f3e8f3b1 Merge remote-tracking branch 'origin/fix/audit-dkp-price' into fix/audit-all 2026-08-30 00:14:23 +05:00
df9dd52996 fix(mera/public): Infinity/NaN во входе — 422, и бюджет считает такие запросы
Аудит живого сайта 30.08.2026: POST /api/public/mera/coverage с
{"lat":56.8,"lon":1e400,...} отвечал 500, и двенадцать таких запросов подряд
дали двенадцать пятисоток и ни одного 429. Две независимые поломки в одном
месте, обе воспроизведены локально до правки.

1. 500 вместо 422. json.loads принимает Infinity/-Infinity/NaN, а 1e400 даёт
   inf переполнением. Pydantic отбивает такое поле по границам и кладёт
   значение в input ошибки, а ответ об ошибке сериализуется
   json.dumps(allow_nan=False) и падает уже после входа в ответ. Ломается не
   поле, а сборка ответа об ошибке — одна на всё приложение, поэтому и
   обработчик один (app/core/http_errors.py), а не валидатор на lon.

2. Лимитер мимо. _enforce стоял первой строкой тела хендлера, а FastAPI
   валидирует тело позже зависимостей, но раньше тела — до проверки просто не
   доходило. Та же поправка места, что уже сделана сегодня у
   _require_public_estimate_enabled: перенос в dependencies. Сделано для всех
   ручек файла, не только coverage. У /estimate и /estimate/read флаг остаётся
   первой зависимостью — 429 на выключенной ручке подтверждал бы её
   существование.

Тесты двусторонние: снятие обработчика роняет 4 проверки 422, возврат лимитера
в тело роняет проверку бюджета (проверено).
2026-08-30 00:11:56 +05:00
c467584de3 fix(mera/landing): «Цена ДКП» — цена договора, а не произведение; экспозиция считает и Домклик
Витрина показывала fact_rub = price_per_m2 * area_m2, хотя deals.price_rub
лежит в той же строке и не использовалась. price_per_m2 в базе integer,
поэтому под подписью «Цена ДКП» ехала реконструкция: 4 799 995 вместо
4 800 000, 3 649 995 вместо 3 650 000 (прод, сделки 5777343 и др.).
Теперь price_rub едет из выборки (DealSample.price_rub) и показывается как
есть; err_pct считается от той же величины. Строка без price_rub НЕ
показывается — подставлять реконструкцию в одну строку из двадцати значило бы
спрятать тот же дефект (на проде price_rub заполнен у 33 555 из 33 555 сделок
выборки витрины).

Вторая находка аудита (listing_date якобы «когда увидели МЫ», экспозиция
занижена втрое) НЕ ПОДТВЕРДИЛАСЬ. listing_date пишут cian (added_ts), yandex
(creationDate) и avito (дата карточки выдачи) — это дата публикации у
источника. Там, где заполнены и listing_date, и publish_date, они совпадают:
yandex 10 761 из 10 903, avito 474 из 569, медиана разницы 0 дней. 75 дней у
аудитора — эффект другой ВЫБОРКИ: publish_date есть у 15 058 активных строк
(yandex + Домклик, оба старые), listing_date — у 25 982 (плюс cian с медианой
17 дней и 87% avito с медианой 19).

Настоящий дефект рядом: по одному listing_date Домклик выпадал целиком (0 из
3061 активной строки), метрика считалась по 83.6% активных объявлений, и
подпись об этом молчала. COALESCE(listing_date, publish_date) → охват 95.2%
(29 568 из 31 068), медиана та же — 26 дней; охват теперь назван в note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 00:10:36 +05:00
2390eec740 fix(mera/b2c): вернуть координаты сделки, снесённые моим же коммитом про студию
Коммит 555cce44 обещал в сообщении одно — подписать студию вместо «0-к», — а
сделал ещё и другое: удалил миграцию 280_landing_showcase_deals_coords.sql и
откатил правки mera.py, landing_showcase_deals.py и двух тестов из коммита
cd2a7671. Заметил не я: об этом написал агент, которому потом достался номер
281 и который увидел дыру на 280.

ПРИЧИНА. `git commit` фиксирует ИНДЕКС ЦЕЛИКОМ, а не то, что было добавлено
последним `git add`. В индексе главного рабочего дерева лежали staged-удаления,
оставшиеся от параллельных агентов (они работают в своих worktree, но индекс
основного дерева переживает переключения веток). Я добавил два файла, а
закоммитил вместе с ними чужие удаления.

Что восстановлено: миграция 280 целиком, поля lat/lon в ShowcaseDeal, перенос
координат в пересчёте витрины, оба теста.

Обе величины нужны и не заменяют друг друга: схема улицы (281) закрывает 92%
сделок, координата (280) — карту района для остальных 8%. Конфликты сведены
вручную: механическое «оставить обе стороны» задвоило список колонок в INSERT
и в SELECT, что тесты бы пропустили, а прод — нет.

Проверено: 5085 passed, 35 skipped; миграции 275-281 без дыры; список колонок
INSERT сверен со списком значений программно (15 и 15, порядок совпадает).
2026-08-29 23:25:44 +05:00