From 6ffcd78d352783e7a41f64b1dc9edef10a3e3f94 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 17:38:55 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/avito):=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20SERP=20=D0=BE=D1=82=D0=B4=D0=B0?= =?UTF-8?q?=D1=91=D1=82=2060=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D0=BA,=20=D0=B0=20=D0=BD=D0=B5=2050?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замер живьём 2026-08-21 через tradein-browser (camoufox, JS исполняется): 59-60 уникальных `data-item-id` на странице выдачи. «Лишние» сверх 50 — обычные объявления с платным продвижением (`vas-icon_type-promoted`), они лежат в том же списке под `page-title/count`, а не отдельным рекламным блоком, и собираются наравне с остальными. Направление эффекта важно понимать правильно. Константа участвует ТОЛЬКО в `ceil(total / PAGE)`, поэтому занижение размера страницы ЗАВЫШАЛО расчётное число страниц, а не занижало: - запрашивали примерно на 17 % страниц больше, чем нужно; при доле банов 37-83 % по avito-заданиям лишние запросы — основная цена ошибки; - `tail_loss` считался как `total - cap * 50` и завышал потерю; - флаг `complete` в пагинации листа чаще ложно показывал «неполно». Тихой потери данных НЕ было: условия «страница вернула меньше PAGE карточек, значит последняя» в коде нет, пагинация ограничена только `max_pages`. Тест закрепляет и значение, и направление арифметики, чтобы неверная трактовка не вернулась при следующем рефакторинге. Найдено при разборе Авито сверкой живого браузера со скраппером; полный разбор — в волте `research/Avito_Live_Browser_Recon_0821.md`. Refs #3033 --- .../tests/test_avito_offers_per_page.py | 61 +++++++++++++++++++ .../src/scraper_kit/providers/avito/serp.py | 18 +++++- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_avito_offers_per_page.py diff --git a/tradein-mvp/backend/tests/test_avito_offers_per_page.py b/tradein-mvp/backend/tests/test_avito_offers_per_page.py new file mode 100644 index 00000000..6c47857c --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_offers_per_page.py @@ -0,0 +1,61 @@ +"""Регрессия на размер страницы Avito SERP. + +Замер живьём 2026-08-21 (tradein-browser / camoufox, JS исполняется): страница +выдачи отдаёт 59-60 уникальных `data-item-id`. Константа стояла 50 — то есть +`ceil(total / PAGE)` завышал число страниц примерно на 17 %, и каждый лишний +запрос — лишний шанс словить SERP firewall. + +Тест намеренно проверяет НЕ только значение, но и то, что константа +по-прежнему участвует в расчёте страниц: иначе правку значения легко потерять +при рефакторинге, оставив мёртвую константу. +""" + +from __future__ import annotations + +import ast +import math +from pathlib import Path + +from scraper_kit.providers.avito import serp + +_SERP_SOURCE = Path(serp.__file__) + + +def test_offers_per_page_matches_live_measurement() -> None: + """60, а не 50 — замер 2026-08-21 через реальный браузер.""" + assert serp._AVITO_OFFERS_PER_PAGE == 60 + + +def test_pages_needed_arithmetic_uses_the_constant() -> None: + """`ceil(total / PAGE)` при 60 даёт меньше страниц, чем при 50. + + Закрепляет направление эффекта: занижение размера страницы ЗАВЫШАЛО число + запрашиваемых страниц. В PR-разборе это было сформулировано наоборот, и + тест существует, чтобы неверная трактовка не вернулась. + """ + total = 1000 + assert math.ceil(total / 60) == 17 + assert math.ceil(total / 50) == 20 + assert math.ceil(total / serp._AVITO_OFFERS_PER_PAGE) == 17 + + +def test_constant_is_still_wired_into_pagination() -> None: + """Константа реально используется, а не осталась мёртвой после правки.""" + tree = ast.parse(_SERP_SOURCE.read_text(encoding="utf-8")) + uses = sum( + 1 + for node in ast.walk(tree) + if isinstance(node, ast.Name) and node.id == "_AVITO_OFFERS_PER_PAGE" + ) + # 1 присваивание + минимум два места расчёта (pages_needed в бисекции и в + # пагинации листа); если станет меньше — кто-то отвязал константу. + assert uses >= 3, f"_AVITO_OFFERS_PER_PAGE упоминается {uses} раз, ожидалось >= 3" + + +def test_no_stale_fifty_in_page_size_comment() -> None: + """Комментарий рядом с константой не должен утверждать «~50 карточек».""" + src = _SERP_SOURCE.read_text(encoding="utf-8") + marker = "_AVITO_OFFERS_PER_PAGE = " + idx = src.index(marker) + preceding = src[max(0, idx - 1200) : idx] + assert "~50 карточек" not in preceding diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index 701d7ecb..c7526780 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -260,8 +260,22 @@ _AVITO_MIN_BRACKET = 50_000 # Последний брекет ОТКРЫТ (hi=None → pmax не ставится) — ловит весь хвост люкса # без потолка; он крошечный (avito >50М ≈ 73), пагинируется напрямую без бисекции. _AVITO_PRICE_SEED_BRACKETS: list[tuple[int, int | None]] = get_price_seed_brackets() -# Avito SERP показывает ~50 карточек на страницу. -_AVITO_OFFERS_PER_PAGE = 50 +# Avito SERP отдаёт 60 карточек на страницу. Замер живьём 2026-08-21 через +# tradein-browser (camoufox, JS исполняется): 59-60 уникальных `data-item-id` +# на странице выдачи. «Лишние» сверх 50 — обычные объявления с платным +# продвижением (`vas-icon_type-promoted`), они лежат в том же списке под +# `page-title/count`, а не отдельным рекламным блоком, и собираются наравне. +# +# Константа стояла 50 и участвует ТОЛЬКО в `ceil(total / PAGE)` — то есть +# занижение размера страницы ЗАВЫШАЛО расчётное число страниц, а не занижало: +# - запрашивали примерно на 17 % страниц больше, чем нужно (лишние запросы, +# лишняя экспозиция под SERP firewall — при нашей доле банов это и есть +# основная цена ошибки); +# - `tail_loss` считался как `total - cap * 50` и завышал потерю; +# - `complete` (пагинация листа) чаще ложно показывал «неполно». +# Тихой потери данных не было: условия «страница вернула меньше PAGE карточек, +# значит последняя» в коде нет — пагинация ограничена только `max_pages`. +_AVITO_OFFERS_PER_PAGE = 60 def _avito_bisection_config(cap: int) -> BisectionConfig: