fix(tradein/avito): окно ретроспективы под недельный такт + диагноз полного обхода (#2674) #2685

Merged
bot-backend merged 2 commits from fix/2674-avito-full-load-coverage into main 2026-08-05 23:13:40 +00:00
Collaborator

Правка после ревью (коммит 6d763281). Механизм границы окна объяснён неверно
в первой версии, 51% не воспроизводится, критерий приёмки увёл бы в ложный вывод.
Всё три исправлено ниже и в комментарии миграции. Оценка потери была занижена:
не ×3.26, а ×8.7.

1. Окно ретроспективы разошлось с тактом

Каденс avito_full_load задаёт default_params.interval_days, глубину обхода —
incremental_days (since = today - N, ранняя остановка пагинации на первой странице
без карточек listing_date >= since). Два независимых литерала, обязанных совпадать:
129 поставил окно 2 при ежедневном такте (2 >= 1, дыр не было), 206 перевёл такт на
interval_days: 7 и окно не тронул.

Прод (scrape_schedules id=138): {"interval_days": 7, "incremental_days": 2, …}.
Прогон видит [D-2, D] = 3 календарных дня из 7; следующий начинает с [D+5, D+7].
Дни D+1..D+4 — 4 суток из 7 — не попадают ни в один прогон.

Сколько лотов достаёт окно шириной W

Непредвзятый замер по одному прогону — run 2990 (exhaustive, 02.08, 2317 строк
снапшота, 2184 с датой):

W лотов
2 147 текущее окно
6 446 структурный минимум смыкания полос
7 1275 ставим это
12 1278 +3 лота за пять суток окна
  • Окно 2 теряет в 8.7 раза (147 против 1275).
  • Шестёрка теряет две трети семёрки (446 против 1275) — это обрыв, а не экономия.
  • 7…12 — плато: семёрка стоит на самой дешёвой его точке, а не является компромиссом.

Почему обрыв ровно на 7 — это наш парсер, а не Avito

Не недельный авто-подъём площадки, а квантование относительных меток нашим же
_parse_relative_date
(providers/avito/serp.py): «неделю назад» → ровно today-7,
«две недели назад» → ровно today-14. Возрасты 8…13 по этому пути недостижимы
на проде их 3 лота из 2184, это и есть плато. При реальном недельном подъёме полоса
8…13 была бы заполнена непрерывно; плюс пик привязан к дате наблюдения, а не ко дню недели.

Практический вывод от этого крепнет: W=6 режет не по пику распределения, а по границе
квантования
— бакет «неделю назад» становится невидим целиком, а внутри него реальный
возраст от 7 до 13 суток.

Тот же бакет в общей выборке: last_seen_at::date - listing_date по avito за 20 суток —
возраст 0-2 = 594, возраст ровно 7 = 1212 из 3714 датированных наблюдений = 32.6%.
(В первой версии стояло 51% — это знаменатель, урезанный возрастами 0-13. Число 1212 и
«0-2 → 594» воспроизводятся точно.)

Полосы соседних прогонов [D-7, D] и [D, D+7] смыкаются с суточным перехлёстом под
дрейф расписания (замер: last_run 03.08 13:37 → next_run_at 10.08 14:16 = +7 суток 39 минут).

Цена по запросам — растёт НЕ пропорционально лотам

Стоимость бакета — ceil(свежих / 50) страниц с полом в 1-2 страницы. При окне 7 на
бакет приходится ~15-20 свежих (1275 лотов на 77 бакетов = 7 комнатностей × 11 ценовых
seed-брекетов) — меньше одной страницы. Большинство бакетов как стояло на 1-2
страницах, так и останется; глубже пойдут только плотные. Верхняя граница честная и
продом уже пережитая: полный обход без отсечки вообще — 6 ч 59 мин (run 295) и
2 ч 34 мин (run 2990); окно 13-15 UTC ограничивает только старт, не длительность.

Что сделано

  • 215_avito_full_load_window_matches_cadence.sql — выводит окно из фактического
    interval_days строки (не хардкод 7), GREATEST не сужает. Только данные, DDL нет.
    Точное равенство source = 'avito_full_load'. Read-only dry-run на проде: 2 → 7.
  • scheduler._job_avito_full_load — расширяет окно до такта на лету + warning.
  • Мина на случай отката такта впечатана в комментарий миграции: ни миграция
    (GREATEST только расширяет), ни планировщик (расширяет, не сужает) окно не сузят.
    interval_days 7 → 1 при окне 7 даст восьмикратный охват каждый день. Такт и
    окно менять одной правкой.

2. Полный обход не отрабатывает — диагноз

Причина не в площадке. Все пять banned-прогонов avito_full_load_exhaustive
(05.07, 12.07, 19.07, 26.07, 02.08) несут ровно один текст:

avito full load aborted: avito SERP browser-sidecar error (page=N):
browser unavailable (proxy may be down)

Это 503 от своего же сайдкара tradein-browser: _ensure_browser не смог поднять
камуфокс. В те же 45 дней остальные avito-джобы работали — avito_city_sweep 20 done,
avito_newbuilding_sweep 21 done, avito_detail_backfill 63 done. Avito нас не банил.

Корень: до 05.08 в env сайдкара стоял BROWSER_PROXY_AVITO=…@ard.mobileproxy.space:1055
— мёртвый аккаунт (407/refused, #2613). Полный обход проваливался именно в этот
env-fallback, потому что строил свой BrowserFetcher без пула (в отличие от
city/newbuilding sweep, ходящих через shared-browser с пулом). Тот же сигнатурный отказ
у соседней avito_full_load: 22 banned подряд с 04.07.

Чинится не здесь, а уже починено: #2637 (02.08, браузерный путь Авито подключён к
пулу) + #2616 шаг 2 (05.08, снос мёртвых BROWSER_PROXY_*; прод .env.runtime
обновлён 05.08 12:56). Прод подтверждает: 0 лотов 05/12/19/26.07 → 2334 (02.08) и
362 (03.08) — единственные два прогона с ненулевым выхлопом с 03.07.

Остаток, который чинится кодом — в этом PR. Оба пост-фиксовых прогона всё равно
умерли на том же 503, потому что ретраев на него нет вообще:

  • _fetch_serp_html_browser классифицирует 503/"browser unavailable" как is_soft_ban
    и отправляет в бюджет IP-ротации;
  • ротация снята в #2616 шаг 2 → max_rot = 0, _rotate_ip() всегда False
    rot_done < max_rot ложно всегда;
  • бюджет коротких backoff-retry (_AVITO_SIDECAR_TRANSIENT_RETRIES = 2) стоял в else:
    и для soft-ban был структурно недостижим.

Один блип сайдкара стоил бакета, четыре подряд (_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED = 4)
— всего прогона. Сайдкар на таком 503 сам поднимает фоновый retry launch'а, так что повтор
через пару секунд обычно проходит — мы просто не повторяли. Бюджет backoff теперь общий
для обеих причин. Фикс стоит в общей воронке _fetch_serp_html_browser → чинит заодно
городской свип и обход новостроек (ревью: ещё 44 убитых прогона той же причиной; из 96
забаненных за 45 суток 90 несут текст сайдкар-503, настоящий firewall площадки — 4).

Где текст эпика расходится с данными

  • Эпик и 206 читают banned как «площадка палит паттерн полного обхода». Данные:
    площадка ни при чём, это отказ своего сайдкара на мёртвом env-прокси. 206
    замедлил джобу (request_delay_sec 1.0 → 7.0, такт 1 → 7) на основании этого чтения.
  • «Успешным был ровно один прогон из семи» — верно (295, 21.06); «с 21 июня ни одного» —
    формально да по статусу, но 02.08 и 03.08 прогоны собрали данные (2334 и 362 лота)
    и умерли под конец, а не на старте.

Статус banned на такой ошибке остаётся ложью. Здесь не трогаю: набор статусов
ограничен scrape_runs_status_check, а mark_banned в отличие от mark_failed сохраняет
чекпоинт done_buckets. Отдельная задача.

Как проверить эффект на проде

  1. Миграция применилась:
    SELECT source, default_params->>'interval_days', default_params->>'incremental_days'
    FROM scrape_schedules WHERE source LIKE 'avito_full_load%';
    -- ждём: avito_full_load 7 / 7; avito_full_load_exhaustive 7 / NULL
    
  2. Ближайшие плановые прогоны: avito_full_load_exhaustive 09.08 14:02 UTC,
    avito_full_load 10.08 14:16 UTC. Раньше — ручной smoke:
    UPDATE scrape_schedules SET next_run_at = now() WHERE source = 'avito_full_load';
    (подхват ≤60с, scheduler в tradein-scraper).
  3. Критерий — ГЛУБИНА, не объём. unique_fetched почти не сдвинется: первые страницы
    бакета и так полные, прогон с окном 2 уже собирал 2804 лота. Ждать «9-10 тысяч» —
    значит увидеть 4 тысячи и решить, что фикс не сработал. Считать надо полосу, которой
    раньше не было видно:
    -- лоты прогона с датой публикации в полосе [D-7, D-3]
    SELECT count(*) FILTER (WHERE l.listing_date >= CURRENT_DATE - 7
                              AND l.listing_date <= CURRENT_DATE - 3) AS band_7_3,
           count(*) FILTER (WHERE l.listing_date >= CURRENT_DATE - 2) AS band_0_2,
           count(*) AS total
    FROM listings_snapshots s JOIN listings l ON l.id = s.listing_id
    WHERE s.run_id = <run_id>;
    
    Ориентир: у exhaustive run 2990 полоса [D-7, D-3] = 1128 лотов, [D-2, D] = 147.
    У последнего инкрементального прогона с окном 2 (run 3074, оборвался на 350 строках)
    — 144 и 24 соответственно, то есть полоса набиралась только попутно, полными страницами.
  4. Либо по страницам — лог-строка _paginate_incremental_bracket в tradein-scraper:
    avito: <room> [lo, hi] paginated=N pages … incremental since=… stop=….
    Ждём рост paginated на плотных бакетах и stop=early-stop (page all older than since)
    вместо мгновенной остановки.
  5. Ретрай сайдкара виден в логах tradein-scraper:
    avito page=N sidecar soft-ban (status=503) — retry in 2.Xs (left=…) — раньше такой
    строки не было ни одной, сразу шла sidecar error budget exhausted.

Тесты

tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py — 13 тестов.
Фальсификация патч-методом (откат правки → прогон):

Тест Без правки
test_window_widened_to_cadence FAIL (assert 2 == 7)
test_null_interval_days_does_not_crash FAIL (TypeError: int(None))
test_sidecar_503_retried_before_giving_up FAIL (1 вызов вместо 3)
test_sidecar_503_recovers_without_aborting_bucket FAIL (AvitoRateLimitedError)
test_window_wider_than_cadence_left_alone, test_daily_cadence_keeps_window, test_no_window_stays_exhaustive, test_exhaustive_job_ignores_window_params зелёные и без правки — guard'ы на регресс, не доказательство фикса
5 тестов миграции краснеют только отсутствием файла; живой БД в тестах нет, эффект UPDATE проверяется на проде (п. 1)

Полный прогон tradein-mvp/backend: 3492 passed, 9 skipped.
tests/test_search_api.py::test_search_cache_hit падает (401 вместо 200) и на чистом
origin/main
— предсуществующее, не из этого PR, задеселекчено в прогоне.

Refs #2674

> **Правка после ревью (коммит `6d763281`).** Механизм границы окна объяснён неверно > в первой версии, 51% не воспроизводится, критерий приёмки увёл бы в ложный вывод. > Всё три исправлено ниже и в комментарии миграции. Оценка потери была **занижена**: > не ×3.26, а **×8.7**. ## 1. Окно ретроспективы разошлось с тактом Каденс `avito_full_load` задаёт `default_params.interval_days`, глубину обхода — `incremental_days` (`since = today - N`, ранняя остановка пагинации на первой странице без карточек `listing_date >= since`). Два независимых литерала, обязанных совпадать: `129` поставил окно 2 при ежедневном такте (2 >= 1, дыр не было), `206` перевёл такт на `interval_days: 7` и окно не тронул. Прод (`scrape_schedules` id=138): `{"interval_days": 7, "incremental_days": 2, …}`. Прогон видит `[D-2, D]` = 3 календарных дня из 7; следующий начинает с `[D+5, D+7]`. **Дни D+1..D+4 — 4 суток из 7 — не попадают ни в один прогон.** ### Сколько лотов достаёт окно шириной W Непредвзятый замер по **одному прогону** — run 2990 (exhaustive, 02.08, 2317 строк снапшота, 2184 с датой): | W | лотов | | |---|---|---| | 2 | **147** | текущее окно | | 6 | 446 | структурный минимум смыкания полос | | 7 | **1275** | ставим это | | 12 | 1278 | +3 лота за пять суток окна | - Окно 2 теряет **в 8.7 раза** (147 против 1275). - Шестёрка теряет **две трети** семёрки (446 против 1275) — это обрыв, а не экономия. - 7…12 — **плато**: семёрка стоит на самой дешёвой его точке, а не является компромиссом. ### Почему обрыв ровно на 7 — это наш парсер, а не Avito Не недельный авто-подъём площадки, а **квантование относительных меток нашим же `_parse_relative_date`** (`providers/avito/serp.py`): «неделю назад» → ровно `today-7`, «две недели назад» → ровно `today-14`. Возрасты 8…13 по этому пути **недостижимы** — на проде их 3 лота из 2184, это и есть плато. При реальном недельном подъёме полоса 8…13 была бы заполнена непрерывно; плюс пик привязан к дате наблюдения, а не ко дню недели. Практический вывод от этого крепнет: **W=6 режет не по пику распределения, а по границе квантования** — бакет «неделю назад» становится невидим целиком, а внутри него реальный возраст от 7 до 13 суток. Тот же бакет в общей выборке: `last_seen_at::date - listing_date` по avito за 20 суток — возраст 0-2 = 594, возраст ровно 7 = **1212 из 3714** датированных наблюдений = **32.6%**. (В первой версии стояло 51% — это знаменатель, урезанный возрастами 0-13. Число 1212 и «0-2 → 594» воспроизводятся точно.) Полосы соседних прогонов `[D-7, D]` и `[D, D+7]` смыкаются с суточным перехлёстом под дрейф расписания (замер: `last_run` 03.08 13:37 → `next_run_at` 10.08 14:16 = +7 суток 39 минут). ### Цена по запросам — растёт НЕ пропорционально лотам Стоимость бакета — `ceil(свежих / 50)` страниц с полом в 1-2 страницы. При окне 7 на бакет приходится ~15-20 свежих (1275 лотов на 77 бакетов = 7 комнатностей × 11 ценовых seed-брекетов) — **меньше одной страницы**. Большинство бакетов как стояло на 1-2 страницах, так и останется; глубже пойдут только плотные. Верхняя граница честная и продом уже пережитая: полный обход без отсечки вообще — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990); окно 13-15 UTC ограничивает только **старт**, не длительность. ### Что сделано - `215_avito_full_load_window_matches_cadence.sql` — выводит окно из фактического `interval_days` строки (не хардкод 7), `GREATEST` не сужает. Только данные, DDL нет. Точное равенство `source = 'avito_full_load'`. Read-only dry-run на проде: `2 → 7`. - `scheduler._job_avito_full_load` — расширяет окно до такта на лету + `warning`. - **Мина на случай отката такта впечатана в комментарий миграции:** ни миграция (`GREATEST` только расширяет), ни планировщик (расширяет, не сужает) окно не сузят. `interval_days` 7 → 1 при окне 7 даст **восьмикратный охват каждый день**. Такт и окно менять одной правкой. ## 2. Полный обход не отрабатывает — диагноз **Причина не в площадке.** Все пять `banned`-прогонов `avito_full_load_exhaustive` (05.07, 12.07, 19.07, 26.07, 02.08) несут ровно один текст: ``` avito full load aborted: avito SERP browser-sidecar error (page=N): browser unavailable (proxy may be down) ``` Это 503 от **своего же** сайдкара `tradein-browser`: `_ensure_browser` не смог поднять камуфокс. В те же 45 дней остальные avito-джобы работали — `avito_city_sweep` 20 done, `avito_newbuilding_sweep` 21 done, `avito_detail_backfill` 63 done. Avito нас не банил. **Корень:** до 05.08 в env сайдкара стоял `BROWSER_PROXY_AVITO=…@ard.mobileproxy.space:1055` — мёртвый аккаунт (407/refused, #2613). Полный обход проваливался именно в этот env-fallback, потому что строил свой `BrowserFetcher` **без** пула (в отличие от city/newbuilding sweep, ходящих через shared-browser с пулом). Тот же сигнатурный отказ у соседней `avito_full_load`: 22 banned подряд с 04.07. **Чинится не здесь, а уже починено:** #2637 (02.08, браузерный путь Авито подключён к пулу) + #2616 шаг 2 (05.08, снос мёртвых `BROWSER_PROXY_*`; прод `.env.runtime` обновлён 05.08 12:56). Прод подтверждает: 0 лотов 05/12/19/26.07 → **2334** (02.08) и **362** (03.08) — единственные два прогона с ненулевым выхлопом с 03.07. **Остаток, который чинится кодом — в этом PR.** Оба пост-фиксовых прогона всё равно умерли на том же 503, потому что ретраев на него нет вообще: - `_fetch_serp_html_browser` классифицирует 503/"browser unavailable" как `is_soft_ban` и отправляет в бюджет IP-ротации; - ротация снята в #2616 шаг 2 → `max_rot = 0`, `_rotate_ip()` всегда `False` → `rot_done < max_rot` ложно **всегда**; - бюджет коротких backoff-retry (`_AVITO_SIDECAR_TRANSIENT_RETRIES = 2`) стоял в `else:` и для soft-ban был структурно **недостижим**. Один блип сайдкара стоил бакета, четыре подряд (`_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED = 4`) — всего прогона. Сайдкар на таком 503 сам поднимает фоновый retry launch'а, так что повтор через пару секунд обычно проходит — мы просто не повторяли. Бюджет backoff теперь общий для обеих причин. **Фикс стоит в общей воронке** `_fetch_serp_html_browser` → чинит заодно городской свип и обход новостроек (ревью: ещё 44 убитых прогона той же причиной; из 96 забаненных за 45 суток 90 несут текст сайдкар-503, настоящий firewall площадки — 4). ### Где текст эпика расходится с данными - Эпик и `206` читают `banned` как «площадка палит паттерн полного обхода». Данные: **площадка ни при чём**, это отказ своего сайдкара на мёртвом env-прокси. `206` замедлил джобу (`request_delay_sec 1.0 → 7.0`, такт 1 → 7) на основании этого чтения. - «Успешным был ровно один прогон из семи» — верно (295, 21.06); «с 21 июня ни одного» — формально да по статусу, но 02.08 и 03.08 прогоны **собрали данные** (2334 и 362 лота) и умерли под конец, а не на старте. Статус `banned` на такой ошибке остаётся ложью. **Здесь не трогаю:** набор статусов ограничен `scrape_runs_status_check`, а `mark_banned` в отличие от `mark_failed` сохраняет чекпоинт `done_buckets`. Отдельная задача. ## Как проверить эффект на проде 1. Миграция применилась: ```sql SELECT source, default_params->>'interval_days', default_params->>'incremental_days' FROM scrape_schedules WHERE source LIKE 'avito_full_load%'; -- ждём: avito_full_load 7 / 7; avito_full_load_exhaustive 7 / NULL ``` 2. Ближайшие плановые прогоны: `avito_full_load_exhaustive` 09.08 14:02 UTC, `avito_full_load` 10.08 14:16 UTC. Раньше — ручной smoke: `UPDATE scrape_schedules SET next_run_at = now() WHERE source = 'avito_full_load';` (подхват ≤60с, scheduler в `tradein-scraper`). 3. **Критерий — ГЛУБИНА, не объём.** `unique_fetched` почти не сдвинется: первые страницы бакета и так полные, прогон с окном 2 уже собирал 2804 лота. Ждать «9-10 тысяч» — значит увидеть 4 тысячи и решить, что фикс не сработал. Считать надо полосу, которой раньше не было видно: ```sql -- лоты прогона с датой публикации в полосе [D-7, D-3] SELECT count(*) FILTER (WHERE l.listing_date >= CURRENT_DATE - 7 AND l.listing_date <= CURRENT_DATE - 3) AS band_7_3, count(*) FILTER (WHERE l.listing_date >= CURRENT_DATE - 2) AS band_0_2, count(*) AS total FROM listings_snapshots s JOIN listings l ON l.id = s.listing_id WHERE s.run_id = <run_id>; ``` Ориентир: у exhaustive run 2990 полоса `[D-7, D-3]` = 1128 лотов, `[D-2, D]` = 147. У последнего инкрементального прогона с окном 2 (run 3074, оборвался на 350 строках) — 144 и 24 соответственно, то есть полоса набиралась только попутно, полными страницами. 4. Либо по страницам — лог-строка `_paginate_incremental_bracket` в `tradein-scraper`: `avito: <room> [lo, hi] paginated=N pages … incremental since=… stop=…`. Ждём рост `paginated` на плотных бакетах и `stop=early-stop (page all older than since)` вместо мгновенной остановки. 5. Ретрай сайдкара виден в логах `tradein-scraper`: `avito page=N sidecar soft-ban (status=503) — retry in 2.Xs (left=…)` — раньше такой строки не было ни одной, сразу шла `sidecar error budget exhausted`. ## Тесты `tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py` — 13 тестов. Фальсификация патч-методом (откат правки → прогон): | Тест | Без правки | |---|---| | `test_window_widened_to_cadence` | **FAIL** (`assert 2 == 7`) | | `test_null_interval_days_does_not_crash` | **FAIL** (`TypeError: int(None)`) | | `test_sidecar_503_retried_before_giving_up` | **FAIL** (1 вызов вместо 3) | | `test_sidecar_503_recovers_without_aborting_bucket` | **FAIL** (`AvitoRateLimitedError`) | | `test_window_wider_than_cadence_left_alone`, `test_daily_cadence_keeps_window`, `test_no_window_stays_exhaustive`, `test_exhaustive_job_ignores_window_params` | зелёные и без правки — guard'ы на регресс, не доказательство фикса | | 5 тестов миграции | краснеют только отсутствием файла; живой БД в тестах нет, эффект `UPDATE` проверяется на проде (п. 1) | Полный прогон `tradein-mvp/backend`: 3492 passed, 9 skipped. `tests/test_search_api.py::test_search_cache_hit` падает (401 вместо 200) и **на чистом `origin/main`** — предсуществующее, не из этого PR, задеселекчено в прогоне. Refs #2674
bot-backend added 1 commit 2026-08-05 22:42:13 +00:00
fix(tradein/avito): окно ретроспективы под недельный такт + диагноз полного обхода (#2674)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 2m53s
e4ac0365cf
Окно и такт разъехались. Каденс avito_full_load задаёт default_params.interval_days,
глубину обхода — incremental_days (since = today - N, ранняя остановка по listing_date).
Это два независимых литерала, обязанных совпадать: 129 поставил окно 2 при ежедневном
такте (перекрытие было), 206 перевёл такт на 7 суток и окно не тронул. Прогон видит
[D-2, D] = 3 суток из 7; дни D+1..D+4 не попадают ни в один прогон.

Числа с прода (tradein, read-only 2026-08-06). 2026-06-21 — единственный день, когда
оба обхода отработали: инкрементальный run 297 — 2804 unique, exhaustive run 295 —
9992 unique, то есть окно в 2 суток достаёт 28.1% инвентаря. Когорта run 295, не
виденная после 23.06 (listing_date заморожен): полоса [D-2, D] — 189 лотов, полоса
[D-7, D-3] — ещё 427, расширение окна берёт в 3.26 раза больше. Гистограмма
(last_seen_at::date - listing_date) за 20 суток: возраст 0-2 — 594, возраст ровно 7 —
1212 (51% датированных наблюдений) — у Avito недельный авто-подъём, sortTimeStamp
сдвигается кратно 7. Структурный минимум бездырочного покрытия — 6, но 6 режет ровно
по этому пику; 7 = такт, полосы соседних прогонов смыкаются с суточным перехлёстом
под дрейф расписания (замер: last_run 03.08 13:37 -> next_run_at 10.08 14:16).

Цена: страниц примерно втрое больше на прогон, но прогон недельный. До 206 система
платила ~150-370 страниц семь раз в неделю; после правки — ~500-1200 в неделю, всё
ещё примерно вдвое дешевле, чем до 206.

Чиню в двух местах: миграция 215 выводит окно из фактического interval_days строки
(GREATEST — не сужает окно шире такта), scheduler расширяет его на лету и пишет
warning, чтобы расхождение не вернулось следующей правкой каденса.

Полный обход: причина не в площадке. Все пять banned-прогонов
avito_full_load_exhaustive (05.07-02.08) несут один текст —
"browser-sidecar error: browser unavailable (proxy may be down)", то есть 503 от
своего же сайдкара, у которого не поднялся камуфокс. В те же дни avito_city_sweep
(20 done), avito_newbuilding_sweep (21) и avito_detail_backfill (63) работали.
Корень — мёртвый BROWSER_PROXY_AVITO (ard.mobileproxy.space) в env сайдкара, куда
полный обход проваливался, потому что строил свой BrowserFetcher без пула; починено
не здесь, а #2637 (02.08, пул для браузерного пути Авито) и #2616 шаг 2 (05.08,
снос мёртвых env). Прод подтверждает: 0 лотов 05/12/19/26.07, 2334 и 362 в двух
прогонах после 02.08.

Остаток, который чинится кодом, здесь: 503 сайдкара классифицировался как soft-ban и
уходил в бюджет IP-ротации, а ротация снята (#2616 шаг 2, max_rot=0) — условие
rot_done < max_rot ложно всегда, а бюджет коротких backoff-retry стоял в else и был
для soft-ban недостижим. Ни одного ретрая на самую частую ошибку: один блип сайдкара
стоил бакета, четыре подряд — всего прогона. Бюджет backoff теперь общий для обеих
причин; сайдкар на таком 503 сам поднимает фоновый retry launch'а, поэтому повтор
через пару секунд обычно проходит.

Статус banned на такой ошибке остаётся ложью (площадка не банила) — это же чтение
легло в основание 206. Здесь не трогаю: набор status ограничен CHECK-констрейнтом,
а mark_banned в отличие от mark_failed сохраняет чекпоинт done_buckets.
Light1YT added 1 commit 2026-08-05 23:09:13 +00:00
fix(tradein/avito): верное объяснение границы окна и критерий приёмки по глубине (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m52s
6d76328168
Разбор ревью PR #2685. Выбор окна в 7 суток подтверждён непредвзятым замером по
одному прогону (run 2990, exhaustive 02.08, 2184 датированных строки): W=2 -> 147,
W=6 -> 446, W=7 -> 1275, W=12 -> 1278. Шестёрка теряет две трети семёрки, а 7..12 —
плато в +3 лота, то есть семёрка стоит на самой дешёвой его точке. Потеря окна 2
занижена мной в первом заходе: не 3.26x, а 8.7x.

Механизм объяснён неверно. Пик на возрасте ровно 7 — не недельный авто-подъём Авито,
а квантование нашего же парсера относительных дат: «неделю назад» -> ровно today-7,
«две недели назад» -> today-14, возрасты 8..13 по этому пути недостижимы. То самое
плато (3 лота из 2184) это и доказывает: при реальном подъёме полоса 8..13 была бы
заполнена. Вывод от этого только крепнет — шестёрка режет не по пику распределения,
а по границе квантования и теряет бакет «неделю назад» целиком, а внутри него
реальный возраст от 7 до 13 суток.

51% не воспроизводится: 1212 из 3714 датированных наблюдений — 32.6%. Пятьдесят один
получается только на знаменателе, урезанном возрастами 0-13.

Цена по запросам описана неверно и в опасную сторону. Рост не пропорционален лотам:
стоимость бакета — ceil(свежих/50) страниц с полом 1-2, при окне 7 на бакет выходит
~15-20 свежих (1275 на 77 бакетов), то есть меньше страницы. Большинство бакетов как
стояло на 1-2 страницах, так и останется. Верхняя граница честная и продом пережитая:
полный обход без отсечки — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990).

Отсюда же переписан критерий приёмки: ждать «9-10 тысяч собранных лотов» нельзя, это
уведёт в ложный вывод. Прогон с окном 2 уже собирал 2804 лота, потому что первые
страницы всё равно полные — объём почти не сдвинется, сдвинется глубина. Считать надо
лоты с listing_date в полосе [D-7, D-3] и число страниц из лог-строки paginated=.

Впечатана мина на случай отката такта: ни миграция (GREATEST только расширяет), ни
планировщик (расширяет до такта, не сужает) окно не сузят, поэтому interval_days
7 -> 1 при окне 7 даст восьмикратный охват каждый день. Такт и окно менять вместе.

int(params.get("interval_days", 1)) падал на значении null в jsonb — соседний параметр
строкой выше обрабатывался через явную проверку на None, этот нет.
bot-backend merged commit 5e92810d72 into main 2026-08-05 23:13:40 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2685
No description provided.