From 6d76328168739338adb1ca1c4c34fa01dd31546c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 04:09:05 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/avito):=20=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BE=D0=B1=D1=8A=D1=8F=D1=81=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20?= =?UTF-8?q?=D0=BE=D0=BA=D0=BD=D0=B0=20=D0=B8=20=D0=BA=D1=80=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=B9=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=B3=D0=BB=D1=83=D0=B1=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбор ревью 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, этот нет. --- ...avito_full_load_window_matches_cadence.sql | 57 +++++++++++-------- .../test_2674_avito_full_load_coverage.py | 5 ++ .../scraper_kit/orchestration/scheduler.py | 5 +- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql b/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql index 35bd4fe5..03b78b11 100644 --- a/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql +++ b/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql @@ -17,30 +17,41 @@ -- (57%) структурно вне поля зрения источника. -- -- ЧИСЛА, обосновывающие новое значение (прод, tradein): --- * 2026-06-21 — единственный день, когда оба Avito-обхода отработали успешно: --- инкрементальный (окно 2) run 297 — 2804 unique, exhaustive (без отсечки) --- run 295 — 9992 unique. Окно в 2 суток достаёт 28.1% того, что достаёт --- полный обход; 7188 лотов (71.9%) лежат ниже отсечки. --- * Когорта run 295, не виденная после 2026-06-23 (listing_date заморожен): --- в полосе [D-2, D] — 189 лотов, в полосе [D-7, D-3] — ещё 427. Расширение --- окна 2 -> 7 берёт в 3.26 раза больше лотов. --- * Гистограмма (last_seen_at::date - listing_date) по avito за последние 20 --- суток: возраст 0-2 — 594 лота, возраст ровно 7 — 1212 лотов (51% всех --- датированных наблюдений). У Avito недельный авто-подъём: sortTimeStamp --- сдвигается кратно 7 суткам, поэтому на возрасте ровно 7 стоит пик. --- Структурный минимум бездырочного покрытия — 6 (при 6 полосы соседних --- прогонов смыкаются), но 6 режет ровно по этому пику. 7 = такт: полосы --- [D-7, D] и [D, D+7] смыкаются с однодневным перехлёстом, который --- покрывает дрейф расписания (замер: last_run 2026-08-03 13:37 -> --- next_run_at 2026-08-10 14:16 = +7 суток 39 минут за цикл). +-- * Замер по ОДНОМУ прогону (run 2990, exhaustive 2026-08-02, 2317 строк +-- снапшота / 2184 с датой) — сколько лотов достаёт окно шириной W суток: +-- W=2 -> 147 W=6 -> 446 W=7 -> 1275 W=12 -> 1278 +-- Три вывода. Окно 2 теряет в 8.7 раза (147 против 1275). Шестёрка теряет +-- две трети семёрки (446 против 1275) — это обрыв, а не экономия. И 7..12 — +-- ПЛАТО: +3 лота на пять дополнительных суток окна, то есть семёрка стоит +-- на самой дешёвой точке плато, а не является компромиссом. +-- * Обрыв на 7 и плато за ним — свойство НЕ Avito, а КВАНТОВАНИЯ нашего же +-- парсера относительных дат (_parse_relative_date, providers/avito/serp.py): +-- «неделю назад» -> ровно today-7, «две недели назад» -> ровно today-14. +-- Возрасты 8..13 по этому пути недостижимы — на проде их 3 лота из 2184 +-- (это и есть плато). Значит бакет «возраст 7» — не «поднятые ровно неделю +-- назад», а ВСЁ, чему реально от 7 до 13 суток. W=6 режет не по пику +-- распределения, а по ГРАНИЦЕ КВАНТОВАНИЯ и теряет бакет целиком. +-- * Тот же бакет в общей выборке: (last_seen_at::date - listing_date) по +-- avito за 20 суток — возраст 0-2 = 594, возраст ровно 7 = 1212 из 3714 +-- датированных наблюдений (32.6%). +-- * Полосы соседних прогонов [D-7, D] и [D, D+7] смыкаются с суточным +-- перехлёстом, который покрывает дрейф расписания (замер: last_run +-- 2026-08-03 13:37 -> next_run_at 2026-08-10 14:16 = +7 суток 39 минут). -- --- ЦЕНА ПО ЗАПРОСАМ. Пагинация останавливается по глубине окна, поэтому число --- страниц растёт примерно как число лотов в окне: ~3.3x к прогону. Прогоны с --- окном 2 при delay=1.0: 2804 lots / 40 мин (run 297) .. 3989 lots / 99 мин --- (run 620) ≈ 150-370 страниц. Окно 7 -> ≈ 500-1200 страниц НА ПРОГОН, но --- прогон теперь недельный, а не ежедневный: до 206 система платила те же --- 150-370 страниц СЕМЬ раз в неделю (~1050-2600). После этой правки — --- ~500-1200 в неделю, то есть по-прежнему примерно вдвое дешевле, чем до 206. +-- ЦЕНА ПО ЗАПРОСАМ растёт НЕ пропорционально лотам. Стоимость бакета — +-- ceil(свежих / 50) страниц с полом в 1-2 страницы; при окне 7 на бакет +-- приходится ~15-20 свежих (1275 лотов на 77 бакетов = 7 комнатностей x 11 +-- ценовых seed-брекетов) — МЕНЬШЕ одной страницы. Большинство бакетов как +-- стояло на 1-2 страницах, так и останется, глубже пойдут только плотные. +-- Верхняя граница честная и продом уже пережитая: полный обход без отсечки +-- вообще — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990); окно 13-15 UTC +-- ограничивает только СТАРТ прогона, не длительность. +-- +-- ВОЗВРАЩАЕТЕ ЕЖЕДНЕВНЫЙ ТАКТ — ВЕРНИТЕ И ОКНО. Ни эта миграция (GREATEST +-- только расширяет), ни планировщик (расширяет до такта, не сужает) окно НЕ +-- сузят. interval_days 7 -> 1 при incremental_days = 7 даст ежедневный прогон +-- с семисуточной глубиной, то есть восьмикратный охват КАЖДЫЙ день. Такт и +-- окно менять одной правкой. -- -- Значение НЕ хардкодим числом 7, а выводим из фактического interval_days строки: -- если такт когда-нибудь поменяют снова, повторный прогон файла (или ручной diff --git a/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py b/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py index 3fc5cd88..e8f8bcf2 100644 --- a/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py +++ b/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py @@ -87,6 +87,11 @@ async def test_daily_cadence_keeps_window() -> None: assert await _incremental_days_passed({"incremental_days": 2}) == 2 +async def test_null_interval_days_does_not_crash() -> None: + """`"interval_days": null` в jsonb приезжает сюда как None, а int(None) — TypeError.""" + assert await _incremental_days_passed({"interval_days": None, "incremental_days": 2}) == 2 + + async def test_no_window_stays_exhaustive() -> None: """Строка без incremental_days = полный обход; такт не должен её «инкрементализировать».""" assert await _incremental_days_passed({"interval_days": 7}) is None diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 8c5e9519..e012353d 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -526,7 +526,10 @@ async def _job_avito_full_load( # incremental_days=2 из миграции 129 → 3 календарных дня из 7 в поле зрения. # Два независимых литерала, которые обязаны совпадать, однажды уже разъехались — # поэтому расхождение чиним здесь, а не только данными. - interval_days = max(1, int(params.get("interval_days", 1))) + # None-safe так же, как incremental_days выше: `"interval_days": null` в jsonb + # приезжает сюда как None, а int(None) — TypeError. + _interval_days = params.get("interval_days") + interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1 if incremental_days is not None and incremental_days < interval_days: logger.warning( "avito_full_load: окно ретроспективы incremental_days=%d уже такта "