Commit graph

176 commits

Author SHA1 Message Date
caa3b8b056 fix(tradein): пустой пул ДО первого объявления теряет диагноз в записи прогона (#3384)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 4m55s
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
Lease берётся один раз в BrowserFetcher.__aenter__, поэтому на проде с пустым пулом
NoProxyAvailableError вылетает из самого `async with` — ДО первой карточки и мимо
стоп-механики внутри цикла (no_proxy_stop = True; break), которая и пишет
counters.no_proxy_stop. Общий `except Exception` ловил его и делал mark_failed с
нулевыми counters без ключа: прогон, который к площадке не ходил вообще, в SQL-разборе
простоя по counters.no_proxy_stop (#3288/#3367) не находится.

Дыра одинаковая у всех трёх бэкфиллов (avito её тоже не обрабатывал: __aenter__
вызывается напрямую строкой 427, отказ уходит в тот же общий except). Правка — в трёх
уже существующих обработчиках, которые и так зовут mark_failed: ключ no_proxy_stop=1
при caused_by_no_proxy(exc). Оборачивать `async with` в try/except пришлось бы с
переносом ~200 строк тела под новый отступ в каждом файле, и покрывало бы только
падение на входе; здесь ловится любой путь мимо цикла.

Тест — через настоящий BrowserFetcher: подделан только провайдер прокси (его acquire
поднимает NoProxyAvailableError), отказ рождается там же, где в проде. Проверяется
failed + no_proxy_stop=1 + attempted=0 (у циана listings_processed=0) и ноль POST'ов
в сайдкар.

Closes #3384
2026-09-06 05:48:32 +05:00
8129e21b95 fix(tradein/cian): суточный бэкфилл истории — в прокси-пул, а не в env-прокси (#3197)
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 4m51s
BrowserFetcher(source="cian") в cian_history_backfill конструировался без
proxy_provider/use_pool/environment — трёх аргументов, которые кладут "proxy" в тело
POST /fetch. Сайдкар брал свой env-прокси (SCRAPER_PROXY_URL): пул из 4 узлов, его
баны и ротация проходили мимо, а прод-отказ «пул пуст → не ходить на env/direct»
(#2616) на этом пути был мёртв, потому что смотрит на environment. Проводка теперь
как у соседей — domclick_detail_backfill и house_imv_backfill.

Ожившему отказу нужен обработчик: NoProxyAvailableError ловился общим except на
объявление, и батч крутил впустую весь список (пул пуст с первого — значит пуст и на
1000-м). Распознаём по цепочке причин, обрываем прогон, counters.no_proxy_stop=1 и
mark_failed вместо mark_banned — отказ нашей стороны не должен записываться как бан
Циана. Дома и оценки после стопа пропускаем: они идут через тот же пул.

caused_by_no_proxy вынесен в scraper_kit.proxy_errors (у avito #3288 и domclick #3283
живут приватные копии — их схлопывание отдельной правкой).
2026-09-06 02:27:03 +05:00
7afaa12d75 Merge pull request 'fix(tradein/avito): брейкер добора считает долю только по отказам площадки — сайдкар/пустой пул не рвут прогон и не красят его banned' (#3367) from fix/3288-breaker-block-kind into main
Some checks failed
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 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Failing after 32s
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
2026-09-05 19:38:48 +00:00
78e3da6bc3 fix(tradein): маркер полноты — encryptedPhones ИЛИ redirectPhones (#3191)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 5m21s
Ревью #3364. Требуя именно encryptedPhones, отбраковывали бы вечно
необмеренный класс карточек «без телефона / только чат»: в очередь они
возвращаются, а ключ не появится. redirectPhones измерен тем же замером
#3192 и присутствует в обеих ветках (с куками и без).

Текст ABORT: consecutive_none смешанный (фетч-ошибка + parse-None +
недогруз) — «N подряд без обогащения», а не «недогруженных».
2026-09-06 00:14:43 +05:00
1a693192e1 fix(tradein): вид блока решает обрыв по доле и статус прогона (#3288 часть B)
Some checks failed
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 / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m19s
Прогон 5425 оборвался по доле блоков и получил статус banned на 48 «блоках»,
из которых 41 был отказом нашего сайдкара: record_block() вида не принимал,
поэтому infra падал в числитель скользящего окна #3184 наравне с настоящим
баном, а mark_backfill_finished считал диагноз только ради телеметрии.

- record_block(kind): в числитель идёт только platform; всё остальное — в
  знаменатель (как record_failure), мимо серии и safety-net;
- NoProxyAvailableError у avito — не блок и не отказ площадки: прогон
  завершается no_proxy_stop=1 + mark_failed «пул прокси пуст» (как домклик
  после #3283); опознаётся по цепочке __cause__, не по подстроке (#3272);
- статус banned — только при доминировании platform; при infra прогон
  получает failed (нулевой результат) или done, с честной причиной.
2026-09-06 00:09:37 +05:00
4b5f8fb54c fix(tradein): недогруженная карточка Яндекса — отказ, а не успех (#3191)
All checks were successful
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 / 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 5m11s
Страница на 1,8 МБ без блока контактов приходит с HTTP 200 и валидным HTML:
window.INITIAL_STATE на месте, parse отрабатывает — и частичная карточка уезжала
в БД с detail_enriched_at, выбывая из очереди навсегда. Единственная проверка
размера (newbuilding.py, len(html) < 500) отвечала на вопрос «пришло ли хоть
что-то»: 1,8 МБ проходит её в 3600 раз.

Признак полноты структурный + размерный, любой из двух даёт отказ:
encryptedPhones (65 вхождений у полных карточек, 0 у недогруза; отдаётся и
анонимной сессии — см. yandex_session.py) и settings.yandex_detail_min_html_bytes
(1 МБ). Наблюдавшийся недогруз ловит именно структурный: 1,8 МБ порог проходит.

В backfill проверка стоит ДО parse: исход incomplete ⊆ failed, save не
вызывается, значит detail_enriched_at не проставляется и следующий снапшот
(detail_enriched_at IS NULL) возьмёт объявление снова. Серия недогрузов двигает
consecutive_none — тот же брейкер, что у parse→None, поэтому вечно недогружаемая
карточка обрывает прогон, а не молотится (per-listing счётчика попыток в схеме
нет).

Фейковые ответы в тестах-соседях (#3196/#3338) теперь при HTTP 200 выглядят
полной страницей — иначе они молча стали бы кейсами про полноту.
2026-09-05 23:59:15 +05:00
659543f004 fix(tradein/backfill): warning save-False называет оба id (avito) и не дублирует listing_id (yandex)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 17s
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 6m13s
Ревью #3347, два minor.

1. avito: WHERE в save_detail_enrichment ключуется по source_id из РАЗОБРАННОГО
   HTML (enrichment.item_id), а warning печатал row["id"] из снимка. При
   редиректе/подмене карточки строка с row-id жива, и читатель лога идёт искать
   несуществующую проблему не у той строки («текст ошибки называет гонца»).
   Печатаем оба: listing_id=%s item_id=%s.
2. yandex: listing_id стоял в строке дважды (в начале и как «id=%d» в хвосте) —
   убран дубль.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:06:22 +05:00
3658434c03 fix(tradein/backfill): save-False у yandex/avito больше не теряет попытку — attempted == сумма исходов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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) Successful in 5m41s
Тот же дефект, что #3332 у domclick (#3335), у обоих соседей: `if save(...):
enriched += 1` без else. UPDATE, не задевший строку (объявление удалено между
снимком и записью), оставлял попытку без исхода — attempted переставал сходиться
с суммой исходов, и расхождение читается как потерянный отказ площадки.

Разбор ВСЕХ точек выхода из цикла попыток показал, что у соседей это
единственная дыра: обрыва «пул прокси пуст» у них нет (resolve_proxy_url
бросает ProxyPoolExhaustedError ДО цикла), а budget/SIGTERM-брейки стоят до
`attempted += 1`. Исход — failed с отдельным warning про ненайденную строку.

Формы тождества разные и это не описка: у yandex blocked ⊆ failed (#3196),
у avito blocked/gone/failed — непересекающиеся корзины.

Тесты — по значению (числа, не «не бросило»), плюс контроль на противоположную
ошибку (исход не начисляется дважды). В test_3332 добавлен запрошенный ревью
кейс: транспортный сбой → пустой пул даёт attempted=2 failed=2, а не 3.

Closes #3338
2026-09-05 22:47:08 +05: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
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
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
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
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
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
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
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
ca3f073b0e feat(mera/b2c): улица сделки доезжает до витрины готовой схемой, а не геометрией
Карта в карточке игры показывала полигон района — единственную геометрию, до
которой у tradein был доступ. Улицы живут в базе gendesign, foreign table и
гранта на них не было.

Мост по образцу соседей (v_tradein_cad_buildings / v_tradein_osm_poi_ekb):
gendesign 195 — вьюха v_tradein_osm_roads_ekb (highway + water) с GRANT В ТОМ
ЖЕ ФАЙЛЕ (после #3227 грант отдельной миграцией теряется при пересоздании);
tradein 281 — foreign table gendesign_osm_roads_ekb плюс колонки street_name /
street_scheme в landing_showcase_deals.

Пересчёт витрины кладёт в строку УЖЕ СПРОЕЦИРОВАННЫЕ SVG-пути окна 840x840 м
вокруг центра улицы. Проекция — та же равнопромежуточная с cos(широты), что в
export_ekb_districts_svg.py; второй в проекте нет. Замер 2026-08-29: GeoJSON
того же окна 7-8 КБ на строку, схема — 2.8-2.9 КБ.

ЧТО ДАННЫЕ ВЫДЕРЖИВАЮТ, И НИ СЛОВОМ БОЛЬШЕ
  · Это УЛИЦА, а не дом: deals.address уровня улицы, номер дома у 2.7% сделок.
    В схеме намеренно НЕТ координат окна и констант проекции — точку дома по
    ней нельзя поставить даже случайно. Это замок, а не забывчивость.
  · Зданий нет: cad_buildings — 18 307 контуров на город, в плотном центре 51
    здание на радиус 450 м, где их в разы больше. Нарисованная застройка
    заявляла бы полноту, которой в данных нет.
  · Улицы — фильтрованная выгрузка «источников шума»: именованные покрыты
    хорошо, дворовые и служебные проезды отсутствуют.
  · Улица сматчилась у 550 названий из 654 — 31 410 сделок из 34 021 (92.3%).
    Остальным street_scheme = NULL, и это штатно: фронт показывает район,
    который для этого и оставлен.
  · Перекрёсток («Челюскинцев/Шейнкмана») берём первой улицей: таких адресов
    три на 34 021 сделку, и обе улицы одинаково верны на уровне улицы.
  · Наличие схемы НА ОТБОР СТРОК НЕ ВЛИЯЕТ — то же правило, что запрещает
    отбор по величине ошибки: иначе витрина показывала бы не работу оценщика,
    а те 92% адресов, что удобно легли на OSM.

Тесты (каждый сломан вручную и покраснел): нормализация на реальных адресах
включая ё/е и «8 Марта»; недоступная вьюха и упавший запрос дают None, а не
исключение; схема не раздувается — потолок в байтах на реальной плотности плюс
прямая проверка округления до 0.1.
2026-08-29 23:25:44 +05:00
4e3279d448 fix(mera/b2c): студия подписывалась как «0-к» на витрине сделок
Увидел на скриншоте карточки игры: «0-к, 25,9 м²». Замер на проде — 3 строки
витрины из 20 имеют rooms = 0, то есть каждая шестая карточка так и выглядит.
«0-к» читается как ошибка выгрузки, а не как тип квартиры.

Студией её называет и наш собственный бэктест (per_rooms.label в
scripts/backtest_estimator.py), и рынок.

Тест двусторонний и фальсифицирован: возврат «0-к» для rooms=0 красит его по
значению, обратная правка — снова зелено.
2026-08-29 23:25:44 +05:00
f3401badd0 feat(mera/b2c): координата сделки доезжает до витрины — с записанной границей честности
Карта лэндинга не может показать точку, пока её нет в витрине: район, комнаты,
площадь и квартал в `landing_showcase_deals` есть, координаты не было.

Что сделано: миграция 280 добавляет lat/lon (double precision, NULLABLE),
задача пересчёта переносит их из `deals`, ручка /showcase отдаёт их как
Optional[float].

ГРАНИЦА ЧЕСТНОСТИ, записанная в трёх местах (COMMENT колонок, `note` каждой
строки, докстринг задачи), а не только в голове автора: это ЦЕНТРОИД УЛИЦЫ, а
не дом. Замер на проде 2026-08-29 по той самой выборке, из которой набирается
витрина (deals, city='Екатеринбург', deal_date >= '2025-01-01'): 34 021 сделка,
34 017 с координатой, но РАЗЛИЧНЫХ точек всего 991 — ≈34 сделки в одной точке,
при 2.7% известных номеров дома. Точка верна на масштабе района и улицы и
неверна на масштабе дома; `note` едет на фронт вместе с числами, поэтому
следующий, кто возьмётся зумить карту, об этом споткнётся.

NULLABLE и без отбраковки: строка без координаты остаётся на витрине с
lat=lon=None. Выбрасывать сделку за отсутствие точки — отбор по признаку, не
связанному с качеством оценки, то есть та же порча витрины, которую здесь уже
чинили (порог по величине ошибки).

Тесты двусторонние, каждый проверен фальсификацией — краснеет по ЗНАЧЕНИЮ:
  * перестановка lat/lon в build_row  → 60.6055 == 56.8386
  * `if lat is None: return None`     → None is not None
  * перестановка lat/lon в ручке      → 60.6055 == 56.8386
  * фильтр строк без координат в ручке → len([]) == 1
Перестановку широты и долготы не ловит ни схема, ни тип (обе float), поэтому
в фикстурах намеренно непохожие величины: 56.8386 против 60.6055.

Фронтенд не тронут — его делает следующий шаг.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:25:44 +05:00
abe559cf8f fix(mera): витрина больше не отсеивает промахи оценщика, счётчики едут на фронт
Ревью MAJOR по честности, два пункта.

1. Убран MAX_ABS_ERR_PCT = 40 из build_row. Докстринг модуля сам запрещает
отбор по величине ошибки, но запрет был реализован только в _sort_key, а
фильтр — тот же отбор ступенькой раньше, и злее: строка не попадала даже в
кандидаты. Обоснование «отклонение >40% — почти всегда занижение ДКП ради
налога» не держится: _load_sample уже режет выборку санитарным диапазоном
₽/м² (для ЕКБ это глобальные PPM2_MIN=30k / PPM2_MAX=600k — город намеренно
не заведён в deal_city_price_bands), то есть грубые занижения вырезаны выше
по потоку и ПО СВОЙСТВУ САМОЙ СДЕЛКИ. Всё, что после этого дало большую
ошибку, — работа оценщика, и посетитель обязан её видеть. Честность про
заниженные ДКП перенесена в note каждой строки.

Заодно убраны MIN_FACT_PPM2=30k (дублировал уже применённый фильтр) и
MAX_FACT_PPM2=1.2M (недостижим при потолке выборки 600k): из трёх отбраковок
в проде срабатывала ровно одна — та, что льстила витрине, а два мёртвых
порога читались как работающие. Осталась только структурная отбраковка «нет
прогноза / квартала / площади».

2. Счётчики прогона выведены в ответ ручки. Итог пересчёта пишется в
landing_showcase_runs (миграция 277) и уезжает в ShowcaseResponse.stats
вместе с правилом отбраковки: показано 20 из N годных, рассмотрено M сделок.
Отдельная таблица, а не колонки в строках, — иначе в самом важном случае
(показывать нечего) счётчики исчезли бы вместе со строками. Ручка теперь
берёт и строки, и числа ИЗ ОДНОГО прогона: иначе пустой прогон показал бы
вчерашние строки под сегодняшними счётчиками.

Тесты двусторонние и проверены на сломанном коде: возврат любого порога по
ошибке → красный с величиной отклонения в сообщении; возврат любой границы
₽/м² → красная своя половина; stats=None при живом прогоне → красный.
2026-08-29 19:20:04 +05:00
b72dbc5da3 feat(mera): витрина лэндинга на реальных ДКП-сделках вместо выдуманных
Лента «МЕРА сказала X — продали за Y» жила на константах в marketing-v3.ts.
Здесь появляется её настоящий источник: сделки Росреестра по ЕКБ, прогнанные
через тот же спайн оценщика, что и боевой расчёт (backtest_estimator).

Отбор строк идёт по полноте данных и свежести квартала и НЕ смотрит на
величину ошибки: отбор по малой ошибке дал бы формально работающий код и
врущую витрину — показанные строки перестали бы быть выборкой из работы
оценщика. Свойство закреплено двусторонним тестом.

Витрина не показывает адреса (номер дома есть у 2.7% сделок) и не показывает
дня сделки (deal_date — первое число квартала). Каждая строка несёт note о
том, что замер не point-in-time. Заниженные ради налога ДКП отбрасываются по
|отклонению| > 40% и ₽/м² вне [30k; 1.2M], счётчик отброшенного — в лог.
2026-08-29 19:20:04 +05:00
e9a2fff0b3 test(mera/b2c): гейт на сам SQL доли снижений + чистка протухших метрик
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 16s
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 5m32s
Ревью: гейт охранял не то место. Подмена знаменателя красила три теста, но
дефект «84.8% вместо 48.1%» живёт в SQL — во включении однострочных записей
истории (у domklik одна запись = «цену не менял») в знаменатель. Ревьюер
вернул дефект условием n_rows >= 2 в CTE moved, и все 25 тестов остались
зелёными: текстовые пины держали только span_days и max_abs_pct.

Новый пин держит обе половины: однострочные попадают в moved веткой CASE со
значением 0, и нигде в запросе нет фильтра по числу записей истории (ни в
WHERE, ни HAVING). Живой прогон на подготовленных строках не заведён
намеренно: DATABASE_URL в тестовой джобе — заглушка, Postgres там нет, и тест
по образцу test_purge_expired_trade_in_data.py молча скипался бы, то есть не
гейтил бы ничего. Фальсифицировано руками — с n_rows >= 2 тест красный и
называет причину.

Второе: метрика, у которой пропал вход, больше не доживает в таблице со
старым computed_at (ручка отдавала её неотличимо от свежей). Строки вне
сегодняшнего набора удаляются в той же транзакции. На ПУСТОМ наборе чистка
не ходит: разом отвалившиеся все входы — признак поломки прогона, а не пяти
одновременных «данных больше нет». Оба поведения покрыты тестами, оба
проверены на сломанном коде.
2026-08-29 19:10:04 +05:00
b5645ec1bc feat(mera/b2c): витринные метрики лэндинга считаются по проду
All checks were successful
CI Trade-In / 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 / changes (pull_request) Successful in 13s
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
Числа на публичном лэндинге лежали литералами во фронте
(mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока
годности. Теперь их считает ночная задача и отдаёт публичная ручка,
вместе с размером выборки и описанием того, что именно измерено.

Что считается: число расчётов и период работы, медиана аналогов на
расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок
продажи — так и написано в note), доля снижавших цену и медианное
снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев.

Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет
стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в
сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель
доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие
цену; считая только по менявшим, получили бы 85% вместо честных 48%.

Метрика без входных данных строку НЕ пишет: подставленный ноль читался
бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500.

«Точность прогноза» и «срок продажи» здесь не считаются намеренно —
таких величин в данных нет.
2026-08-29 18:45:26 +05:00
bot-backend
fd95c962bb fix(tradein/domclick): backfill ходил в сайдкар мимо прокси-пула (#3197 ч.1)
Some checks failed
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 / 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 4m53s
BrowserFetcher(source="domclick", reuse_context=True) конструировался без
proxy_provider/use_pool/environment -- тела POST /fetch не несли "proxy",
сайдкар брал свой env-прокси, и прогон шёл мимо пула целиком: ни выбора узла
по affinity, ни scrape_proxy_source_bans, ни ротации при блоке. Тот же дефект
уже чинили на avito_detail_backfill/house_imv_backfill (#2698) -- этот call
site оставался последним непочиненным. environment обязателен: без него
отказ «пул пуст» на этом пути мёртв (#2616 шаг 1). reuse_context=True
сохранён без изменений.

Заодно поправлен устаревший комментарий над конструктором: ссылался на
scrape_proxies.provider_affinity='domclick' и миграцию 173 -- на проде
такого больше нет (миграция 253 сняла резервацию узла, #2800), все четыре
включённых узла (id 1/9/10/11) имеют provider_affinity='any'.

test_3118_domclick_warm_context.py обновлён под новую сигнатуру вызова
(assert_called_once_with -> точечная проверка нужных kwargs).
2026-08-29 15:26:14 +03:00
bot-backend
09bdd7888b fix(tradein/domclick): перезапуск браузера на карточку уничтожал пропуск QRATOR (#3212)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 19s
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 1m10s
CI Trade-In / backend-tests (pull_request) Successful in 5m23s
ДомКлик закрыт QRATOR с proof-of-work: первый запрос отдаёт 401 и заглушку с
задачей, браузер её решает, дёргает /__qrator/validate и получает пропуск в куках
(qrator_jsid2 + qrator_jsr). Пропуск живёт в cookie jar, то есть в browser
context'е — а прод брал каждую карточку в новом контексте, выбрасывая его.
Повторная валидация с того же IP получала 403 и страницу bot-mitigation.

Замер (прод, прод-прокси, по 6 карточек):
  общий контекст, одна вкладка       6/6, validate не вызывался ни разу
  общий контекст, вкладка на карточку 6/6, validate не вызывался ни разу
  новый контекст на карточку (= прод) 1/6, validate = [304, 403] на каждой
  боевой путь /fetch с reuse_context  6/6 при 0 перезапусков и 1 контексте

Что правится:
  * снят код-дефолт domclick=1 из #3205: перезапуск процесса гарантированно
    уничтожает контекст, то есть лечил симптом, который сам же и создавал.
    Ручка per-provider и domclick как отдельный провайдер остаются;
  * сброс контекста в бэкфилле был на КАЖДЫЙ блок — стал один раз за прогон.
    Это и объясняет провал #3193: сброс выбрасывал пропуск, следующий фетч
    блокировался гарантированно, что снова вызывало сброс. Приёмка тогда дала
    ровно 1 успех из 10;
  * снята неверная формулировка «отказ, а не челлендж» из #3204/#3205 —
    26 624 байта это РЕЗУЛЬТАТ проваленного PoW, а не статика вместо него.
    Ошибка вышла из метода: HTML читали на 4.5-й секунде и не смотрели в сеть.

Замер 6/6, которым обосновывали #3205, был испорчен: в логах сайдкара после
каждой страницы стоит «recycle threshold (1) достигнут, перезапуск браузера».

Тесты: два кодировали domclick=1 — переписаны через подставной словарь, чтобы
уровень «код-дефолт поставщика» продолжал проверяться, а не исчез вместе с
записью. Тест сброса требует ровно одну попытку за прогон.
159 passed (сайдкар), 4972 passed / 37 skipped (backend).
2026-08-29 14:09:23 +03:00
bot-backend
663f426d4c fix(tradein/domclick): отказ с кодом 401 классифицировался как причина неизвестной природы (#3196)
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 / 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 4m55s
Замер на проде 2026-08-29: страница отказа ДомКлика при HTTP 401 — РОВНО
26 624 байта, байт в байт та же, что снималась 28.08 под кодом 403. Ни PoW,
ни QRATOR, ни капчи. Приходит одинаково и с валидной сохранённой сессией
(16 куков из domclick_session), и полностью анонимно — значит это не
«сессия отвергнута», а WAF-отказ с подменённым кодом ответа.

В таблице классификатора 401 не было (403/429 → platform, 5xx → infra),
поэтому все три пробы подряд дали ban_kind='unknown' — ровно то, что #3196
и должен был убрать. Механика диагноза при этом рабочая: статус доезжает от
page.goto до исключения целым, шов blocked.status = status подтверждён живым
401 на проде.

Правка доменная — в _ban_kind_of_block домкликового таска, а не в общей
scraper_kit.browser_fetcher.ban_kind_from_status: у других поставщиков 401
обычно значит «наша сессия протухла», это наша сторона, и метка 'platform'
там зря запустила бы ротацию IP (#2611).
2026-08-29 12:19:18 +03:00
cdcb152d76 Merge pull request 'fix(tradein/scrapers): ABORT-лог называл серию блоков, хотя рвал прогон по доле' (#3201) from fix/3184-abort-log-names-wrong-criterion 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 43s
Deploy Trade-In / test (push) Successful in 4m10s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 2m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
Reviewed-on: #3201
2026-08-28 20:27:36 +00:00
bot-backend
bf3214b9e4 fix(tradein/scrapers): диагноз блока брался из текстовых маркеров чужой площадки, а не из HTTP-статуса (#3196)
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 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m57s
Сайдкар вообще не читал код ответа page.goto: страница классифицировалась
только по маркерам, снятым с Авито. Домклик отдаёт статическую `403 | Домклик`
на 26 624 байта, где нет ни одного такого маркера (замер прода 28.08.2026) —
она уезжала наверх как валидный HTML, парсер не находил состояние, и прогон
получал блок неизвестной природы. За 14 дней все 14 прогонов домклика легли с
ban_kind='unknown'; у Яндекса счётчика blocked не было вовсе, поэтому ветка
перевода прогона в 'banned' была недостижима по построению — ноль банов.

- browser/server.py: статус целевой навигации сохраняется per-provider и
  доезжает в тело /fetch аддитивным ключом "status" (ключ "html" не тронут);
  403/429 с маркерами челленджа больше не ждут PoW — ждать нечего, статическая
  страница сама себя не перезагрузит. Наверх идёт BanPageDetectedError, а не
  заглушка: вернув её контентом, воскресили бы #3045.
- scraper_kit/browser_fetcher.py: BrowserFetcher.last_response_status +
  ban_kind_from_status (403/429 → platform, 5xx → infra, прочее → None).
  Поток управления не менялся: fetch() по-прежнему отдаёт str.
- domclick: DomClickBlockedError несёт .status — один тип исключения на
  маркер-детект и на сбой фетча разводится без размножения типов; прогон
  передаёт перепись диагнозов в mark_backfill_finished.
- yandex: появился счётчик blocked, оживляющий ветку бана. Серии блоков и
  промахов парсера считаются РАЗДЕЛЬНО: иначе четыре промаха плюс один 403
  пятым давали 'banned' с переписью {platform: 1}.
- cian: ban_kinds наполняется только диагностируемым статусом. HTTP 200 с
  пустым разбором — дрейф разметки на нашей стороне, а не отказ площадки;
  записав его блоком, мы бы штамповали фиктивные баны у здорового источника
  (13 done против 1 banned за 14 дней).

Инвариант: непустой ban_kinds ⟺ виден ответ 403/429/5xx. Значения остаются в
пределах CHECK scrape_runs.ban_kind.

Известный пробел: шов providers/domclick/detail.py `blocked.status = status`
тестами не покрыт — существующие домкликовые тесты подают исключение готовым
моком и боевой fetch_detail не исполняют.
2026-08-28 23:21:54 +03:00
bot-backend
8176e8d167 fix(tradein/scrapers): ABORT-лог называл серию блоков, хотя рвал прогон по доле
All checks were successful
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 Trade-In / changes (pull_request) Successful in 7s
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 4m49s
Прод-прогон 5210 оборвался по ratio-критерию — 14 блоков из 20, ровно порог 0.7 —
и отчитался строкой «ABORT -- 1 consecutive blocks». Число верное: последняя серия
в тот момент действительно равнялась единице (19-я попытка успех, 20-я блок).
Величина не та. Читатель лога видит цифру, по которой обрыва быть не могло, и идёт
искать несуществующий баг в брейкере.

Причина: #3184 заменил критерий обрыва на долю в скользящем окне, а текст лога
остался от прежнего критерия «N подряд» — то есть ровно та же болезнь, которую
#3178 лечил у соседней строки (литерал «IP rate-limited» вместо измеренной причины).

- BlockRatioBreaker.abort_reason() возвращает "ratio" / "safety_net" / None;
  should_abort() выражен через него, поведение не меняется.
- abort_explanation() даёт текст с той величиной, по которой обрыв и произошёл:
  доля печатает «доля блоков 14/20 в окне (порог 70%)», safety-net — «5 блоков
  подряд без единого успеха (снапшот 5 короче окна 20)».
- counters["abort_reason"] — чтобы причина обрыва читалась SQL-запросом по
  scrape_runs, а не грепом контейнера. Ключа нет, если прогон не обрывался.

Тесты (проверено мутацией источника — на прежнем сообщении оба падают):
- ratio-обрыв на раскладке прогона 5210 (серия на обрыве = 1) требует «14/20»
  в логе и отсутствия слова consecutive;
- safety-net требует «5 блоков подряд» и отсутствия «доля блоков» — без этого
  зеркала первый тест проходил бы и у сообщения, всегда печатающего долю;
- прогон без обрыва (13/20) не пишет abort_reason в counters.

Refs #3184, #3178
2026-08-28 23:04:07 +03:00
5be64c6688 fix(tradein/scrapers): хранилище авторизованной сессии Яндекс.Недвижимости (#3195)
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) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 4m4s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m0s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-28 19:02:05 +00:00
49b70a67f3 fix(tradein/scrapers): обрыв по серии блоков рвал каждый прогон, включая здоровые (#3188)
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 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 1m37s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
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
2026-08-28 18:22:25 +00:00
e685f96107 fix(tradein/scrapers): диагноз блока терялся при схлопывании, а в алерт шла непроверенная причина (#3183)
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 3m59s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
2026-08-28 17:15:02 +00:00
bot-backend
8cdb195e18 fix(tradein/geocode): бюджет прогона не был потолком — проверялся только между батчами
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 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 / backend-tests (pull_request) Successful in 4m45s
CI / backend-tests (pull_request) Has been skipped
`run_geocode_missing_listings` рекламирует `budget_sec` как максимальное время
прогона, но проверка стояла после возврата из батча. Внутри батча цикл шёл по
всем 200 адресам и часов не смотрел, то есть фактический потолок был
`budget_sec + один полный батч`.

Замер: прогон 5017 (27.08, `budget_sec=1800`) шёл 3150 с — 175 % бюджета, и
вышел не по бюджету, а по дренажу: бюджетная ветка за 52 минуты не выполнилась
ни разу.

Пока адрес стоил ~1.9 с это терялось в шуме. После общего ограничителя темпа
Nominatim (#2953) средняя цена 4.5 с, а на трудном хвосте (tier-1 + до 4
typo-вариантов под паузой 1 с, плюс retry×3) — до 33 с. Полный батч из таких
адресов уезжает на ~110 минут поверх бюджета, при окне расписания 06:00–09:00.

Дедлайн теперь передаётся В батч и проверяется на каждом адресе. Оборванный
батч — штатный исход: `geocode_tried_at` проставлен только у обработанных пар,
остальные попадут в выборку следующего прогона.

Отдельный флаг `budget_exhausted` нужен потому, что `addresses_total` на
оборванном батче равен размеру ВЫБОРКИ (== batch_size) — ветка дренажа
`addresses_total < batch_size` не сработала бы, и обёртка крутила бы цикл
дальше. Тест на это падает без флага (проверено снятием ветки).

Тесты: 5 новых, все три несущие проверки падают без соответствующей правки.
37 passed локально.

Closes #3151
2026-08-27 19:10:53 +03:00
bot-backend
dd3a79b475 style(tradein): уложить перенесённые строки в лимит 100 символов
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 4m48s
Ruff E501 на трёх строках, которые удлинились от замены литерала на
`DEFAULT_IMPERSONATE` в докстроках. Абзацы перевёрстаны целиком, а не
разорваны по месту переполнения — рваный перенос читался бы как опечатка.

Прогон: `ruff check app tests` — All checks passed.

Refs #3148
2026-08-27 18:24:09 +03:00
bot-backend
cf3d4850e2 fix(tradein/scrapers): три прод-пути остались на chrome120, пока kit ушёл на chrome146
Some checks failed
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) Failing after 1m3s
#3034 свёл impersonate к единственной константе внутри scraper_kit и поднял
профиль до chrome146. Сторож литерала сканирует только пакет, а его докстрока
объявила остальное «отдельным периметром вне scope», сославшись на #2361 F4a.
Периметр не спящий — он ходит в сеть каждый день, а #2361 к тому моменту был
закрыт, то есть отсылка вела в никуда.

На chrome120 оставались:

  app/services/cian_session.py:164        верификация куки Циана
  app/services/yandex_address_backfill.py:153   бэкфилл адресов
  app/tasks/yandex_detail_backfill.py:303       detail-бэкфилл

Разрыв в 31 мажорную версию живёт в TLS-отпечатке (JA3/JA4), а не в строке
User-Agent, поэтому сменой прокси он не лечится.

ПРО ЦИАН ОТДЕЛЬНО. По #2673 оценка Циана мертва с 29 июня — «куки протухли,
ни одной новой строки 37 дней». Путь, которым проверяется живость этих куки,
всё это время представлялся площадке браузером двухлетней давности. Причину
этим не объявляю: утверждаю, что при таком отпечатке отличить «куки протухли»
от «нас узнали по рукопожатию» нечем.

ТЕСТ ЗАКРЕПЛЯЛ ДЕФЕКТ. test_cian_session прибивал chrome120 гвоздём: подъём
профиля в kit ронял бы этот тест, а «починкой» выглядел бы возврат к
устаревшему профилю. Теперь тест сверяется с DEFAULT_IMPERSONATE.

Сторож литерала расширен на backend/app — без этого периметр возвращается
молча, что уже один раз и произошло. Намеренно НЕ входят tests/fixtures/**
(номер профиля там — часть записи о том, чем снят фикстур-HTML) и scripts/**
(разовые инструменты, в прод-путях не участвуют).

Исторические замеры в комментариях сохранены как замеры: «curl_cffi с
kit-профилем (на момент замера — Chrome 120)» вместо переписывания истории.

Проверено: сканер сторожа на дереве даёт ноль нарушителей, на подсаженном
литерале краснеет; все изменённые модули компилируются.

Refs #3148
2026-08-27 18:18:05 +03:00
bot-backend
d66a52d492 fix(tradein/avito): браузерный бэкфилл ходил мимо пула прокси
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m53s
Ветка browser_mode в avito_detail_backfill конструировала BrowserFetcher без
proxy_provider/use_pool/environment. Без них фетчер не кладёт "proxy" в тело
POST /fetch, сайдкар берёт свой env-прокси, и прогон уходит мимо пула целиком:
ни выбора узла по affinity, ни учёта scrape_proxy_source_bans, ни ротации при
блоке. В логе это ровно `proxy_lease_id=None`.

Ровно этот дефект чинили рядом — #2698 в house_imv_backfill, где он держал
35 отказов из 35 попыток в каждом прогоне полтора месяца, пока соседние свипы
через ТОТ ЖЕ сайдкар тянули сотни объявлений. Здесь он остался.

Замер 27.08, прогон 5098:

    mode=browser, proxy_lease_id=None
    BLOCKED #1..#5 подряд — firewall/soft-block (browser-mode)
    ABORT — 5 consecutive blocks, enriched=0 attempted=5

При этом пул здоров — 4 узла, все ok, ни один не занят, браузерная проверка
пройдена в то же утро. А тот же URL Авито через прокси отдаёт 200 и 3.3 МБ
страницы. То есть площадка нас пускала, запрос шёл не оттуда.

Это объясняет, почему предыдущая правка (#3143, прокси в повторах curl-пути)
не восстановила сбор: боевой режим бэкфилла — browser, и он до curl-веток
вообще не доходит.

Три теста: конструктор на месте (страховка от проверки пустоты), все три
аргумента проводки передаются, use_pool читается из конфига а не зашит
константой (зашитый True отнял бы у владельца выключатель, зашитый False вернул
бы дефект незаметно). Проверил красноту на коде без проводки.
Прогон: 113 тестов зелёные, ruff чист.

Refs #3045, #3034, #2698
2026-08-27 15:58:26 +03:00
3d0541b2e2 Merge origin/main into fix/2656-anchor-ratio-freshness — конфликты: метки #2656→#2661 (main), импорт-union, baseline unrecorded=0 (перезахваченная фикстура) 2026-08-26 18:22:54 +05:00
9810ae350f feat(tradein/deactivate-stale): страховочные рельсы объёма снятия (#3066)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Successful in 3m47s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 2m1s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-08-24 08:44:32 +00:00
bot-backend
fb38d657ad fix(tradein/deactivate-stale): пол переобхода — LATERAL вместо equality-join по дате
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 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 / backend-tests (pull_request) Successful in 4m41s
Правка меняет две вещи разом, обе намеренно.

1. Равенство по дате -> «последняя строка не позже якоря».
   listing_source_snapshots переходит на модель «строка на изменение».
   В ней equality-join теряет 95.6% пар (на 2026-08-20 изменениями являются
   4458 строк из 101795): выборка для percentile_disc схлопывается до n=3-4,
   квантиль вырождается в максимум из трёх чисел, TTL обваливается —
   domklik 28->14 (под снятие сразу 128 активных строк), yandex 44->30,
   avito 13->10.
   Дыры в суточной истории ломали equality-join и до перехода: на проде
   03-14.06 (12 суток подряд), 03-04.07, 12.07, 26.07, 30-31.07, 01.08 —
   там n_pairs=0, floor_days=NULL и TTL молча оставался как задан.

2. Глобальный max(snapshot_date) -> максимум внутри источника.
   Старый подзапрос брал максимум по ВСЕЙ таблице, не скоупленный по
   listing_source_id: источник, чья история короче общей, выпадал из
   выборки целиком. LATERAL ищет предшественника по строке.

Замер на живом проде 2026-08-23 (health_window_days=3): обе формы дают
побитово одинаковый результат на всех четырёх источниках —
avito n=765 пол=49.7, cian n=1226 пол=81.6, domklik n=565 пол=35.7,
yandex n=3856 пол=87.3. Сегодня суточная джоба пишет строку для каждого
источника каждый день, поэтому глобальный максимум совпадает с максимумом
каждого источника, и пункт 2 — no-op на текущих данных. Расхождение
проявится только на дырах и после перехода на change-only.

Плюс floor_n_pairs в counters — наблюдательность для будущего гейта
деградации пола, сейчас ничего не блокирует.

FROM..WHERE вынесен в _revisit_floor_from_where_sql, чтобы count(*) и
percentile_disc гарантированно шли по одному срезу.
2026-08-23 15:45:53 +03:00
bot-backend
fe628a5e17 fix(tradein/scraper-kit): actualize avito fingerprint - impersonate/Sec-Fetch-Site/referer/cookie-check
Живой замер браузера 2026-08-21 разошёлся с тем, что шлёт прогретая сессия:

- impersonate="chrome120" был захардкожен в 9+ местах (avito/{serp,detail,imv,
  houses}.py, pipeline.py x4, cian/valuation.py, yandex/valuation.py) вместо
  единой DEFAULT_IMPERSONATE (providers/_base.py). Обновление до chrome146
  (макс. доступный профиль curl_cffi 0.15.0; alias "chrome" НЕ используется -
  едет сам при апгрейде библиотеки без ревью) теперь меняется в одном месте.
  Guard-тест backend/tests/test_impersonate_single_source.py грепает всё
  дерево scraper_kit на литерал "chrome120".

- DOCUMENT_HEADERS ставил Sec-Fetch-Site="none" на уровне сессии, а Referer
  добавлялся per-request (curl_cffi мёржит per-request headers поверх
  session-level) - живой Referer с "none" рядом не бывает у настоящего
  Chrome. Добавлен referer_headers() (Sec-Fetch-Site="cross-site") для
  caller'ов с чужедоменным Referer; avito warm-up (yandex/ya.ru -> avito)
  теперь его использует. Внутренний avito-search -> avito-detail Referer
  (fetch_detail, same-origin) НЕ тронут - отдельный явный комментарий почему.

- Referer прогрева заменён с yandex.ru на ya.ru (живой переход из выдачи
  даёт короткий домен). ysclid сознательно не добавлен - значение выпускает
  Яндекс, подделка хуже отсутствия.

- warm_up_session/research_in_session считали прогрев успешным по HTTP-
  статусу и отсутствию firewall-маркеров, не проверяя антибот-cookies
  (__zzatw-*/cfidsw-*, сняты с живого браузера) - detail-батч на такой
  "прогретой" сессии сжигал прокси на обречённых 403. Теперь поднимают
  AvitoWarmupCookiesMissingError (наследник AvitoBlockedError - существующие
  except-блоки/ban_kind/proxy-ротация в pipeline и backfill ловят без
  изменений).

Полный backend pytest suite зелёный (4672 passed), ruff check чист.

Refs #3034
2026-08-21 17:02:53 +03:00
cdf493f345 chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-21 12:01:52 +00:00
507c8e0611 fix(tradein): неудача резолва yandex_jk_slug помечает дом — очередь не упирается в одни и те же пять (#2924)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m25s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
(полное описание — в PR)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:38:22 +05:00
bot-backend
e00dcac177 fix(tradein/deactivate): resolve migration 264 renumber collision on merge with main
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / 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 / 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 4m27s
fix/tradein-ttl-effective-cap (PR #2907, merged into main while this branch was in
flight) already claimed 264/265 for deactivate_stale_avito_cap_mult /
deactivate_stale_yandex_cap_mult. Renumbers this branch's
264_seed_deactivate_stale_null_segment_yandex_cian.sql -> 266_seed_... (git mv +
_manifest_applied.txt entry moved after 264/265 + self-references in the migration
header and in test_deactivate_stale_listings.py's _MIGRATION_264 constant/test names).

Merges main's bool-guard (ttl_days/cap_mult reject bool) + CAP_MULT ceiling +
per-source cap_mult calibration with this branch's null_segment_only kwarg
(explicit `listing_segment IS NULL` predicate, since ANY(:segments) never matches
NULL) -- both features apply to the same deactivate_stale_listings() call site in
product_handlers.py and the same function signature/docstring in
deactivate_stale_avito.py, so every conflict was signature/docstring-level, not
logic-level (git already auto-merged the function body correctly since the two
features touch disjoint lines below the signature). Also reconciled the module
docstring's "known gap" note (main) to reflect that the NULL-segment slice it
measured (cian 211 / yandex 523 rows >60d) is now closed by this migration --
novostroyki stays open, unrelated to this branch.
2026-08-15 21:13:41 +03:00
bot-backend
08cff706ec docs(tradein/deactivate): убрать неверное число из обоснования потолка
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / 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 / 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 4m33s
В шапке модуля, в комментарии миграции 264 и в докстринге теста стояло «23 687 из
44 744 avito-объявлений не подтверждались >7 суток» под заголовком «ЗАМЕР НА ПРОДЕ».

Число реальное, но приписано не тому. Перепроверено запросом 2026-08-15:
это ВСЕ источники вместе, и две трети — новостройки, которых оценщик не берёт
(он фильтрует listing_segment IS NULL OR = 'vtorichka'). У самого avito
просроченных строк ноль: 8 663 активных, максимальный возраст 10 суток.

Оставлять это в коде нельзя: следующий человек прочитает «avito раздут вдвое»,
проверит и не найдёт — а заодно потеряет доверие к остальным числам в том же
абзаце, которые верны и сверены с scrape_runs.counters.

Заодно явно записано, чего потолок НЕ делает: он не сжимает пул (0 деактиваций
замерено на всех четырёх джобах), а защищает от разгона пола и от опечатки в
расписании. Настоящий раздутый срез — строки с пустым сегментом, они чинятся
отдельной джобой.
2026-08-15 21:00:42 +03:00
bot-backend
cfb4c159ab fix(tradein/scraper): deactivate stale yandex/cian listings with NULL segment
544 yandex + 224 cian active rows carry listing_segment=NULL (legacy rows
predating migration 011, plus a small trickle that can never self-heal since
the upsert ON CONFLICT never rewrites listing_segment on re-scrape). 94-97%
of them are frozen at ~86 days old, yet the estimator's Tier A (same-building)
and Tier C (micro-radius) anchor queries filter only is_active=true -- no
freshness column -- so these stale asking prices anchor live valuations.

deactivate_stale_yandex/_cian (migration 115) already run at TTL=30 but scope
segments=['vtorichka'] only: `= ANY(CAST(:segments AS text[]))` never matches
NULL, so the NULL bucket was invisible to both existing jobs and to
avito/domklik/n1 (which are source-blanket or vtorichka-only respectively).

Adds null_segment_only kwarg to deactivate_stale_listings() building an
explicit `listing_segment IS NULL` predicate (confirmations/revisit-floor
builders extended in parallel for correctness, though both gates are kept
off for this slice -- population too small for thresholds calibrated on a
full vtorichka sweep, would permanently skip as unhealthy). Two new
scrape_schedules rows (migration 264) run it per source, untouched
novostroyki/vtorichka jobs unaffected.

TTL=60d (vs 30d for vtorichka): revisit-floor is not computable here (rows
out of dedicated sweep scope have no revisit-gap history), so the margin is
folded into the TTL directly -- 2.26x/1.4x over the measured p99 revisit gaps
already on file for cian/yandex vtorichka (26.6d/43.0d). Bimodal age
distribution means this costs almost nothing in coverage (30d vs 60d: 744 vs
734 deactivated).

First run: 734 of 768 NULL rows deactivated (211 cian, 523 yandex), 34 remain
(fresher than 60d, still incidentally re-touched). Comparable pool
(is_active AND segment IN (NULL, vtorichka)) after: cian -2.7% (7739->7528),
yandex -10.2% (5133->4610) -- yandex crosses the 10% flag threshold. All 734
removed rows already had scraped_at frozen >60d, i.e. already excluded from
Tier S/H (which do filter freshness, 14-60d window) -- the drop is real for
is_active headcount but zero-impact there; it only prunes Tier A/C, where it
removes stale prices rather than live comps.

Separate finding (not fixed here): base.py's ON CONFLICT DO UPDATE omits
listing_segment from SET entirely, so a legacy NULL row can never heal even
though cian/yandex SERP always compute segment deterministically on re-scrape.
2026-08-15 20:44:36 +03:00