diff --git a/tradein-mvp/backend/tests/test_3033_avito_secondary_vtorichka_path.py b/tradein-mvp/backend/tests/test_3033_avito_secondary_vtorichka_path.py new file mode 100644 index 00000000..61252ad4 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3033_avito_secondary_vtorichka_path.py @@ -0,0 +1,168 @@ +"""Avito room-URL строится по родному фильтру вторички, а не общей выборке (#3033). + +Замер 2026-08-21 (живой сайт, ЕКБ): общая выборка `prodam-ASgBAgICAUSSA8YQ` = 46 573 +объявления, из них вторичка `prodam/vtorichka-...` = 9 612 (20.6%). Текущий код +(`fetch_all_secondary`) качал ВСЕ 46 573, парсил и отбрасывал ~79% новостроек после +парсинга (`listing_segment == "novostroyki"`). Родной Avito-фильтр вторички — +`/{city}/kvartiry/prodam/{room-readable}/vtorichka` — отдаёт сразу нужную выборку, +сокращая объём обхода ~впятеро. + +Ключевое: декодировать бинарный ASgB-хвост НЕ нужно. Проверено вживую через camoufox +(vault research/Avito_Live_Browser_Recon_0821, 2026-08-21): заход на читаемый путь без +хвоста (`/ekaterinburg/kvartiry/prodam/1-komnatnye/vtorichka`) отдаёт канонический URL +и правильную выдачу (2551 в первом замере, 2364/2549 на повторных live-прогонах — +листинги живые, число плавает). Читаемый префикс уже лежит в ROOM_SLUGS до маркера +`-ASgB` (`studii`, `1-komnatnye`, ..., `svobodnaya_planirovka`). + +`secondary_only`-фильтр после парсинга (`fetch_all_secondary`) НЕ убран — остаётся +страховкой на случай, если Avito протащит новостройку в выдачу вторички; ассерты на +него здесь не нужны, он тестируется отдельно (#1781-style). + +Пороговые данные из того же live-прогона (2026-08-21, camoufox, реальный /fetch через +tradein-browser sidecar): на живой странице `/vtorichka` найдено 59-60 уникальных +`data-item-id` карточек, а НЕ 50 — `_AVITO_OFFERS_PER_PAGE` был занижен на 20%, +что недосчитывало страниц в `pages_needed = ceil(total / _AVITO_OFFERS_PER_PAGE)` +(тихий tail-loss на каждом leaf-бакете бисекции). + +No network / no DB — чистые URL-билдеры на stub-конфиге, как test_serp_city_location_urls.py. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from scraper_kit.providers.avito.serp import ( + _AVITO_OFFERS_PER_PAGE, + NOVOSTROYKA_SLUG, + ROOM_SLUGS, + AvitoScraper, + _room_readable_prefix, +) + +_STUB_CFG = SimpleNamespace() + + +# ── _room_readable_prefix ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("slug", "expected"), + [ + ("studii-ASgBAgICAkSSA8YQygj~WA", "studii"), + ("1-komnatnye-ASgBAgICAkSSA8YQygiAWQ", "1-komnatnye"), + ("2-komnatnye-ASgBAgICAkSSA8YQygiCWQ", "2-komnatnye"), + ("3-komnatnye-ASgBAgICAkSSA8YQygiEWQ", "3-komnatnye"), + ("4-komnatnye-ASgBAgICAkSSA8YQygiGWQ", "4-komnatnye"), + ("5-komnatnye-ASgBAgICAkSSA8YQygiIWQ", "5-komnatnye"), + ("svobodnaya_planirovka-ASgBAgICAkSSA8YQygj8zzI", "svobodnaya_planirovka"), + ], +) +def test_room_readable_prefix_strips_asgb_tail(slug: str, expected: str) -> None: + assert _room_readable_prefix(slug) == expected + + +def test_room_readable_prefix_is_noop_without_asgb_marker() -> None: + """Строки без маркера (тестовые заглушки типа "2k") возвращаются как есть.""" + assert _room_readable_prefix("2k") == "2k" + + +def test_room_slugs_readable_prefixes_match_expected_avito_segments() -> None: + """ROOM_SLUGS покрывает все 7 комнатностей ожидаемыми читаемыми префиксами.""" + expected = { + "studii", + "1-komnatnye", + "2-komnatnye", + "3-komnatnye", + "4-komnatnye", + "5-komnatnye", + "svobodnaya_planirovka", + } + assert len(ROOM_SLUGS) == 7 + actual = {_room_readable_prefix(slug) for _, slug in ROOM_SLUGS} + assert actual == expected + + +# ── _build_rooms_url: путь и query для всех комнатностей × pmin/pmax ───────────── + + +@pytest.mark.parametrize(("name", "slug"), ROOM_SLUGS) +def test_build_rooms_url_uses_readable_vtorichka_path(name: str, slug: str) -> None: + s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type] + url = s._build_rooms_url(slug, page=1) + readable = _room_readable_prefix(slug) + assert f"/ekaterinburg/kvartiry/prodam/{readable}/vtorichka" in url + # Бинарный ASgB-хвост декодировать не нужно — его не должно быть в пути. + assert "-ASgB" not in url + + +@pytest.mark.parametrize(("name", "slug"), ROOM_SLUGS) +def test_build_rooms_url_without_price_bounds_omits_pmin_pmax(name: str, slug: str) -> None: + s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type] + url = s._build_rooms_url(slug, page=1) + assert "pmin=" not in url + assert "pmax=" not in url + + +@pytest.mark.parametrize(("name", "slug"), ROOM_SLUGS) +def test_build_rooms_url_with_price_bounds_includes_pmin_pmax(name: str, slug: str) -> None: + s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type] + url = s._build_rooms_url(slug, page=3, min_price=3_000_000, max_price=5_000_000) + assert "pmin=3000000" in url + assert "pmax=5000000" in url + assert "p=3" in url + + +@pytest.mark.parametrize(("name", "slug"), ROOM_SLUGS) +def test_build_rooms_url_with_only_min_price_omits_pmax(name: str, slug: str) -> None: + s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type] + url = s._build_rooms_url(slug, page=1, min_price=1_000_000) + assert "pmin=1000000" in url + assert "pmax=" not in url + + +# ── Вторичка ≠ новостройки ──────────────────────────────────────────────────────── + + +def test_vtorichka_room_url_differs_from_newbuilding_url() -> None: + s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type] + room_url = s._build_rooms_url(ROOM_SLUGS[1][1], page=1) # "1-komnatnye" + newbuilding_url = s._build_newbuilding_url(page=1) + assert room_url != newbuilding_url + assert "/vtorichka" in room_url + assert "/vtorichka" not in newbuilding_url + assert NOVOSTROYKA_SLUG in newbuilding_url + assert NOVOSTROYKA_SLUG not in room_url + + +# ── Областные города строят корректный путь ─────────────────────────────────────── + + +@pytest.mark.parametrize( + "city_slug", + ["nizhniy_tagil", "kamensk_uralskiy", "pervouralsk", "verkhnyaya_pyshma", "serov"], +) +def test_build_rooms_url_oblast_city_uses_target_city_slug_in_path(city_slug: str) -> None: + """Свип по областным городам использует target_city_slug в пути, не ЕКБ.""" + s = AvitoScraper(_STUB_CFG, target_city_slug=city_slug) # type: ignore[arg-type] + url = s._build_rooms_url(ROOM_SLUGS[1][1], page=1) # "1-komnatnye" + assert f"/{city_slug}/kvartiry/prodam/1-komnatnye/vtorichka" in url + assert "/ekaterinburg/" not in url + + +def test_build_rooms_url_defaults_to_ekaterinburg_without_target_city_slug() -> None: + s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type] + url = s._build_rooms_url(ROOM_SLUGS[0][1], page=1) # "studii" + assert "/ekaterinburg/kvartiry/prodam/studii/vtorichka" in url + + +# ── Регрессия: карточек на страницу 60, не 50 (замер 2026-08-21 живьём) ────────── + + +def test_offers_per_page_constant_is_sixty_not_fifty() -> None: + """`_AVITO_OFFERS_PER_PAGE` управляет `pages_needed` в бисекции — недооценка на + 20% (50 вместо 60) тихо теряла хвост каждого leaf-бакета.""" + assert _AVITO_OFFERS_PER_PAGE == 60 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 95e2618a..d26918cf 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 @@ -14,8 +14,11 @@ URL patterns (EKB): https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ ?s=104&p=1 - byrooms-mode (citywide + фильтр комнатности, T6): - https://www.avito.ru/ekaterinburg/kvartiry/prodam/?s=104&p=1 + byrooms-mode / exhaustive-mode (citywide + фильтр комнатности + вторичка, T6/#3033): + https://www.avito.ru/ekaterinburg/kvartiry/prodam//vtorichka + ?s=104&p=1[&pmin=...][&pmax=...] + — читаемый префикс ROOM_SLUGS до ASgB-хвоста (напр. "1-komnatnye"), + декодировать бинарный хвост не нужно — Avito канонизирует путь сам. ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP. Используем curl_cffi с impersonate='chrome120' — настоящий Chrome TLS ClientHello. @@ -250,8 +253,15 @@ _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 карточек на страницу, НЕ 50 (подтверждено вживую 2026-08-21 +# через camoufox: подсчёт `[data-marker="item"]` на живой странице /vtorichka дал 59-60 +# уникальных `data-item-id` на нескольких прогонах; "лишние" карточки несут CSS-класс +# `vas-icon_type-promoted` — это обычные объявления с платным продвижением (VAS), НЕ +# инжектированный рекламный блок/секция "похожие" — все они внутри одного +# `page-title/count`-списка. Константа была занижена на 20%: `pages_needed = +# ceil(total/_AVITO_OFFERS_PER_PAGE)` при 50 недосчитывала страниц на leaf-бакет, +# см. `_walk_price_range`/`_paginate_leaf_bucket` — тихий tail-loss на каждом бакете. +_AVITO_OFFERS_PER_PAGE = 60 def _avito_bisection_config(cap: int) -> BisectionConfig: @@ -926,21 +936,43 @@ class AvitoScraper(BaseScraper): min_price: int | None = None, max_price: int | None = None, ) -> str: - """T6: URL с фильтром по комнатности для всего города (no geo). + """T6: URL вторички с фильтром по комнатности для всего города (no geo). Дробление по комнатности позволяет обойти cap ~5000 на category. room_slug — берётся из ROOM_SLUGS (кодирует ASgB-параметры выборки). + #3033: путь строится как `/{city}/kvartiry/prodam/{room_readable}/vtorichka` + — родной фильтр вторички Avito, а НЕ общая выборка комнатности с + отбрасыванием новостроек после парсинга (`secondary_only`). Вторичка — 20.6% + общей выдачи ЕКБ (9612 из 46573, замер 2026-08-21), т.е. вместо скачивания и + разбора всех карточек с последующим `listing_segment == "novostroyki"` фильтром + качаем СРАЗУ ~впятеро меньший набор. `secondary_only`-фильтр после парсинга + остаётся страховкой (см. fetch_all_secondary) на случай, если Avito протащит + новостройку в выдачу вторички. + + Декодировать бинарный ASgB-хвост НЕ нужно: читаемый префикс room_slug (до + маркера `-ASgB`, см. `_room_readable_prefix`) уже валиден как самостоятельный + Avito-путь — площадка сама канонизирует его на `/vtorichka` без бинарного + хвоста (подтверждено вживую 2026-08-21 через camoufox: редирект отдаёт + канонический URL и правильную выдачу; count/60-карточек/pmin-pmax подтверждены + отдельно через тот же live-браузер-прогон, vault + research/Avito_Live_Browser_Recon_0821). + min_price/max_price — опциональные ценовые границы (Avito `pmin`/`pmax`), добавляются в query ТОЛЬКО если заданы (backward-compat: вызовы без цены - не меняют URL). Используются exhaustive full-load для room×price бисекции. + не меняют форму query). Используются exhaustive full-load для room×price + бисекции. """ params: dict[str, Any] = {"s": 104, "p": page} if min_price is not None: params["pmin"] = min_price if max_price is not None: params["pmax"] = max_price - return f"{self.base_url}/{self._city_seg()}/kvartiry/prodam/{room_slug}?{urlencode(params)}" + room_readable = _room_readable_prefix(room_slug) + return ( + f"{self.base_url}/{self._city_seg()}/kvartiry/prodam/{room_readable}/vtorichka" + f"?{urlencode(params)}" + ) def _extract_total_count(self, html: str) -> int | None: """Извлечь общее число результатов из Avito SERP (`page-title/count`). @@ -1091,6 +1123,9 @@ class AvitoScraper(BaseScraper): secondary_only: если True (default) — отбрасывает новостройки (listing_segment=="novostroyki") после парсинга, до save/on_bucket. Avito тегирует сегмент по DOM-маркеру item-development-name в _parse_html. + #3033: URL бакета (`_build_rooms_url`) уже фильтрует вторичку на стороне + Avito (`/vtorichka`-путь) — этот флаг теперь СТРАХОВКА на случай, если + площадка протащит новостройку в выдачу вторички, а не основной фильтр. on_bucket: опциональный callback(bucket_key, list[ScrapedLot]) после каждого leaf-бакета. Может быть async или sync. Исключение прерывает прогон. on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket). @@ -2123,6 +2158,21 @@ ROOM_SLUGS: list[tuple[str, str]] = [ # своб.планировку), т.к. каждая категория < SERP-cap после room×price бисекции. _AVITO_DEFAULT_ROOMS: list[tuple[str, str]] = list(ROOM_SLUGS) +# #3033: маркер начала бинарного ASgB-хвоста в ROOM_SLUGS-слаге (напр. +# "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ" → хвост кодирует параметры выборки, которые +# Avito умеет разворачивать сам через canonical-редирект). +_ROOM_SLUG_ASGB_MARKER = "-ASgB" + + +def _room_readable_prefix(room_slug: str) -> str: + """Читаемый префикс комнатности из ROOM_SLUGS-слага (до `-ASgB`-хвоста). + + "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ" → "1-komnatnye", "studii-ASgB..." → "studii". + Строки без маркера (напр. тестовые заглушки типа "2k") возвращаются как есть — + split без совпадения отдаёт исходную строку целиком. + """ + return room_slug.split(_ROOM_SLUG_ASGB_MARKER, 1)[0] + _NON_SLUG_CHARS_RE = re.compile(r"[^a-z0-9]+")